类似VMWare的虚拟机
VirtualBox的Linux系统各发行版镜像仓库,使用vagrant连接VirtualBox能快速拉取centos镜像通过一行命令快速创建centos虚拟机
详细使用见Linux指南
Kibana的Dev Tools可用于使用Elasticsearch的WEB API,管理和操作ES中的数据
Dev Tools界面使用快捷键Ctrl+Home或者Ctrl+End可以快速跳转至控制台的首行或者最后一行
Dev Tools界面的格式化快捷键为Ctrl+I
Jmeter中选择Options--Choose Language中可以选择简体中文
JMeter设置
【线程组设置】
Thread Group表示发送请求的最小单位是以线程作为单位的
Name是线程组的名称,Comments是该线程组的摘要说明,这两个都可以不填
Number of Threads是并发请求的用户数,意思是给并发请求启动多少个线程来模拟用户数量
Ramp-up period指的是这些线程在多长时间内将这些线程启动起来,可以看做多长时间内把所有请求发送出去,注意这里只是发送请求的时间,总的时间包含等待响应的时间,不同的服务器性能和响应内容可能导致总时间远远超出发送请求的时间
❓:注意不是单个循环发送的时间,因为设置50个循环请求少的时候也是瞬间发完,这里具体的意思还需要明确,倾向于多长时间将这些线程启动起来,因为请求少,每个线程50次请求可以瞬间发完
Loop Count该线程组发送请求循环的次数【每个线程发送多少个请求】,infinite表示无限次发送请求
【请求设置】
点击绿色按钮是启动该线程组,弹框是提示是否保存当前设置的请求计划,点击保存会将请求设置保存在本地的jmeter的bin目录下的Summary Report.jmx文件中【就是持久化操作】,齿轮加两个扫把图标是清空全部统计数据【不清空每次测试数据会累计】
取样器是选择要发送请求的样式
需要在Web Server菜单指定请求的协议,目标服务器的IP地址或者被DNS解析的域名,目标服务器端口号
在Http Request中指定请求的方式和请求路径
使用jmeter分析测试结果
【jmeter的查看测试结果集菜单】
listener【监听器】下有很多展示测试数据的测试结果集选项,比较常用的有以下三种,要像图中这样选中对应的选项卡,在选项卡菜单中运行线程组
View Result Tree:查看结果树,能够看到每次请求的响应状态和对应的响应结果
Summary Report:汇总报告,显示请求总数,平均、最小、最大响应时间,响应时间的方差和标准差【反应每个样本响应时间和平均值的偏差程度】,异常比例,吞吐量【该指标非常重要,通过该指标来衡量接口每秒的并发能力】,每秒接收和发送的网络数据【网络数据太慢会影响对服务器性能的判断,因为发送请求太慢】
View Result in Table:
Aggregate Report:聚合报告,显示样本总量、平均响应时间、响应时间中位数、90%、95%、99%请求完成的时间【单位是毫秒】,请求最小最大响应时间、异常比例、吞吐量、接收和发送网络数据的速率
Aggregation Graph:能够将统计数据以图表的形式进行展示,在列设置中设置柱状图展示的数据类型、
【View Result Tree测试效果】
直接选中对应的结果集选项,并按此前操作点击绿色按钮
在此前的限流设置下,一秒内的十次并发请求,只有第一个成功了
该结果集选项卡下能看到每个请求的请求协议信息、请求的URL和响应数据
【Summary Report测试效果】
Label字段指是发送的是哪一类请求,Http Request表示发送的是http请求,Total表示所有请求的统计信息
Sample表示发送请求的样本个数,我发了3次十个请求,这里就显示的30个
Average、Min、Max、Std.DEV都是响应时间,意思是响应时间的平均值,最小值、最大值和中位值
Error是请求发送发生错误的请求比例有多少
Throughput是指吞吐量,就是指QPS,卧槽我这里怎么是9.6个每分钟,老师的演示是11个每秒,说样本太少,展示数据的结果不准确
Received KB/sec是指每秒接收数据的吞吐量,即网络消耗的吞吐
Send KB/sec是指每秒发送数据的吞吐量
Avg.Bytes是平均每秒数据吞吐量的大小
需要关注的主要就是QPS和发送接收数据的吞吐量,如果QPS怎么也上不去,就像下图演示的情况,此时一定要关注传输数据的吞吐量,因为数据传输的速率是由网卡决定的,比如百兆网卡下载速度应该是100除以8,即12M左右,到达数据传输上限或者延迟很高QPS也是上不去的,不知道是不是我这里的网络太差的原因,但是我这儿用的是同一台主机,难道也是用网卡进行通信的吗
注意上面的工具栏两个小扫把【悬停显示clear】可以清除页面的数据【一个是清除当前,一个是清除所有】,每次发送请求都最好点一下清除数据,方便统计
【View Result in Table测试效果】
能够看到每个请求的开始时间【毫秒级别】,请求线程、响应时间、响应状态、请求和响应数据量
对Nginx服务器无限次请求,观察jmeter统计效果
我这里发了十二万个请求,在nginx服务器的设定限流规则下,只有几个成功了【因为试验了几秒钟】,此时我的QPS上来了,能达到42.4每秒,卧槽老师的都快10000每秒了
连续发起请求,整个过程40多万个请求,可以动态的显示QPS效果。我这里QPS稳定达到了14000左右每秒【最高一万七,一万七不是真实数据,我第二次请求一直单增,但是最终还是稳定1万四】,老师的还是10000左右
虚拟机的配置相比于实际生产机极低,也能到一万多的QPS,QPS高的原因是nginx性能高此外还有请求几乎都报错,即有很多无效的QPS
顶上error和success分别勾选能单独统计成功请求和失败请求的QPS,在ngixn设置的限流策略下随时间推移勾选成功会将QPS稳定在设置值1r/s
而且在View Result in Table中能看到成功请求的时间间隔严格的遵循1s,完全精确到jmeter的时间刻度1ms
JMeter报错Address Already in use
问题描述:Jmeter访问测试本机127.0.0.1的端口服务,在无限请求的情况下请求产生大量异常,异常率迅速飙升超过50%,响应体报错,提示信息Address already in use
原因分析:该问题实际是windows的问题,windows本身提供给TCP/IP的端口是1024-5000,且需要四分钟才会循环回收这些端口,短时间内跑大量的请求会将端口占满
解决方法一:修改windows的注册文件,windows官方文档中指出当尝试大于5000的TCP端口连接时会收到大量错误,可以通过以下方案来解决
1️⃣:在win+r打开窗口中使用命令regedit
打开注册表
2️⃣:选择计算机\HKEY_LOCAL_MACHINE\SYSTEM\CurrentControlSet\Services\Tcpip\Parameters
,
3️⃣:右击parameters
,新建--DWORD32位,修改对应名字为MaxUserPort,点击该MaxUserPort,在弹出窗口将数值改为65534,基数为十进制【这是设置最大可用端口数量】
4️⃣:右击parameters
,新建--DWORD32位,修改对应名字为TCPTimedWaitDelay,点击该TCPTimedWaitDelay,在弹出窗口将数值改为30,基数为十进制【这是】设置windows回收关闭端口的等待时间为30s
5️⃣:退出注册表编辑器,重启计算机配置才能生效
该工具用于远程或者本地监控Java进程的线程数目、线程运行状态、内存占用等信息
Jconsole远程连接Java进程需要被连接Java进程在虚拟机做一些额外配置才能允许远程连接,一般项目上线测试使用Jvisualvm也需要这些配置,弹幕说叫jmx配置
jconsole远程连接Java进程配置
被远程连接Java程序使用以下命令启动
ip地址:这个是当前虚拟机的IP地址,不是jconsole所在主机的IP地址
连接端口:是Jconsole与远程主机的通讯端口,可以随意指定
是否需要安全连接:一般自己用也不需要安全连接选择false,如果需要选择true
是否需要认证:一般自己使用不需要安全认证可以选择false,如果需要选择true
xxxxxxxxxx
java -Djava.rmi.server.hostname=`ip地址` -Dcom.sun.management.jmxremote -
Dcom.sun.management.jmxremote.port=`连接端口` -Dcom.sun.management.jmxremote.ssl=是否安全连接 -
Dcom.sun.management.jmxremote.authenticate=是否认证 java类名
通过该启动参数启动的Java进程并提供了远程监控服务,jconsole可以通过该ip和指定的监听端口连接到远程的Java进程上来进行调试,没有设置认证jconsole直接通过ip地址和端口直连就行,不需要输入用户名和口令;需要服务器开放对应端口通讯
监控堆内存变化、CPU、线程指标的jconsole和jvisualvm工具
这两个工具都是Java提供的,jvisualvm是jdk6以后提供的工具,是jconsole的升级版工具,一般推荐使用jvisualvm,相比于jconsole功能更强大,还可以将运行期间出现的问题以快照的形式下载下来慢慢分析来优化应用
jconsole的使用
安装了java环境直接在CMD窗口敲命令jconsole
启动jconsole控制台
上来就提示需要新建连接,指要连接的具体应用,可以连接本地的,也可以连接远程的,本地进程会列举所有运行java程序的进程名称和对应的进程号,选择对应的应用进行监控
监控面板
概览面板
监控的数据包括堆内存使用量,线程数【压力测试线程数会一直向上涨】,已加载的类数量,CPU占用率
内存面板
绿条第一个是老年代内存,第二个是伊甸园区内存,第三个是幸存者区内存,
线程面板
显示当前的每个线程和对应的堆栈跟踪信息
类面板
显示当前加载的类信息
jvisualvm的使用
安装了java环境直接在CMD窗口使用命令jvisualvm
启动,弹幕说IDEA可以安装VisualVM Launcher插件,启动后选择连接目标进程,注意Java8以后不再自带jvisualvm
概述面板显示了JVM参数和系统变量属性
监视面板显示CPU信息、堆内存信息、线程数,已装载类
压测期间需要观察已经使用的堆空间和已经使用的堆空间大小,线程情况和CPU情况,来观察当前应用到底是局限在CPU的计算上,还是内存经常容易满,还是线程数不够导致运行太慢等等,像下图CPU的使用了一直维持个位数的使用率,说明CPU太闲了
线程面板显示线程的具体信息,还展示当前线程是在运行、休眠【休眠状态是调用了sleep方法的线程】、等待【等待是调用了wait方法的线程】、驻留【线程池中等待接收新任务的空闲线程】以及监视【监视的意思是两个线程发生了锁的竞争,当前线程正在进行等待锁】
项目中还需要监控内存的垃圾回收等信息,jvisualvm默认是不带该功能的,需要安装插件,点击工具--插件,点击可用插件--检查最新版本来测试是否报错无法连接到VisualVM插件中心,如果报错,原因是需要指定插件中心的版本【修改插件中心的地址】,按照以下方式解决
打开插件中心的网址https://visualvm.github.io/pluginscenters.html
使用命令java -version
查看本机的jdk版本java version "1.8.0_101"
,重点关注小版本号101
在插件中心的网址中找到小版本所在对应的版本号区间,拷贝对应版本区间的插件更新地址【点进该地址,复制页面最顶上的地址】
在jvisualvm中点击设置--编辑Java VisualVM插件中心,将地址粘贴到弹出框的URL栏中,点击确定后会自动进行更新
此时就可以直接使用可用插件菜单的插件了,安装不来用个梯子,因为github有可能连不上
在可用插件中选择插件VisualVM GC,通过该插件可以观察到垃圾回收的过程,点击安装,安装完点击文件--退出,重启jvisualvm,面板会多出一个Visual GC面板,其中Old表示老年代,右边的表示新生代【最上面是伊甸园区、下面是两个幸存者区】
GC Time 4875 collections表示总共GC的次数为4875次,后面跟的是GC花费的总时间10.714s
Eden Space中是4872次GC,耗时10.495s,单次约2.15毫秒,下面的图标显示的是内存用量的实时曲线,正常健康的曲线是如下图所示的类直角三角形曲线,意味着伊甸园区的内存满了以后触发一次GC然后内存用量清零
Old Gen是老年代,是3次GC,耗时218.686毫秒,单次约72.9毫秒,性能远远低于YGC,因此线上一定要避免频繁地进行FGC,老年代内存缓慢增长,老年代满了以后执行一次FGC
Metaspace是元空间,是直接操作物理空间的,前面的数字是最大空间,后面的数字是当前用量,元空间的内存用量不需要关心
请求路径带参数
引入依赖
xxxxxxxxxx
<!--mybatisPlus-->
<dependency>
<groupId>com.baomidou</groupId>
<artifactId>mybatis-plus-boot-starter</artifactId>
<version>3.2.0</version>
</dependency>
引入mysql数据库驱动依赖
最好是对应mysql版本的mysql驱动,但是从中央仓库发现没有对应5.7.27的mysql驱动,官网给出的解释是mysql驱动5.1和8.0版本可以适配mysql5.6、5.7和8.0的所有版本,5.1兼容jre1.5、1.6、1.7、1.8,8.0只兼容jre1.8;官方推荐使用8.0版本的mysql驱动
xxxxxxxxxx
<!--mysql驱动-->
<dependency>
<groupId>mysql</groupId>
<artifactId>mysql-connector-java</artifactId>
<version>8.0.17</version>
</dependency>
在application.yml
配置数据源信息
xxxxxxxxxx
Spring
datasource
username root
password Haworthia0715
url jdbc mysql //192.168.56.10 3306?mall_pms
driver-class-name com.mysql.jdbc.Driver
使用MyBatisPlus需要以下配置
启动类上添加注解@MapperScan("com/earl/mall/product/dao")
来告诉MybatisPlus该应用的相关Mapper接口位置
实际该注解写在配置类上即可,接口写成Dao或者Mapper无所谓
xxxxxxxxxx
"com/earl/mall/product/dao") (
public class MallProductApplication {
public static void main(String[] args) {
SpringApplication.run(MallProductApplication.class, args);
}
}
在配置文件application.yml
配置mybatis-plus.mapper-locations
属性来告诉MybatisPlus该应用的相关SQL映射文件的位置
默认配置就是
classpath*:/mapper/**/*.xml
,classpath*
的意思是不仅扫描当前类路径,连引入依赖的类路径下也一起扫描;如果只是classpath
表示只扫描当前类路径下,不扫描引入依赖的类路径
xxxxxxxxxx
mybatis-plus
mapper-locations classpath* /mapper/**/*.xml
在实体类的主键上有注解
@TableId
,如下源码所示,该注解的自增属性默认是没有开启的,属性值为none如果只是在实体类的主键上设置主键类型只会对当前实体类对应的数据库表生效,如果不想每个表都设置一次,可以在配置文件通过属性值
mybatis-plus.global-config.db-config.id-type=auto
来进行设置
x
RetentionPolicy.RUNTIME) (
ElementType.FIELD}) ({
public @interface TableId {
String value() default "";
//默认没有开启主键自增功能
IdType type() default IdType.NONE;
}
实体类的@TableId
注解
xxxxxxxxxx
"pms_attr_attrgroup_relation") (
public class AttrAttrgroupRelationEntity implements Serializable {
private static final long serialVersionUID = 1L;
/**
* id
*/
private Long id;
/**
* 属性id
*/
private Long attrId;
/**
* 属性分组id
*/
private Long attrGroupId;
/**
* 属性组内排序
*/
private Integer attrSort;
}
相关的IdType
属性值
0或者AUTO是自增主键
注意除了主键自增其他的自增是MP自己的填充行为,不需要开启数据库表的主键自增功能,设置了0或者AUTO才需要开启数据库表的主键自增
xxxxxxxxxx
public enum IdType {
/**
* 数据库ID自增
*/
AUTO(0),
/**
* 该类型为未设置主键类型(注解里等于跟随全局,全局里约等于 INPUT)
*/
NONE(1),
/**
* 用户输入ID
* <p>该类型可以通过自己注册自动填充插件进行填充</p>
*/
INPUT(2),
/* 以下3种类型、只有当插入对象ID 为空,才自动填充。 */
/**
* 分配ID (主键类型为number或string),
* 默认实现类 {@link com.baomidou.mybatisplus.core.incrementer.DefaultIdentifierGenerator}(雪花算法)
*
* @since 3.3.0
*/
ASSIGN_ID(3),
/**
* 分配UUID (主键类型为 string)
* 默认实现类 {@link com.baomidou.mybatisplus.core.incrementer.DefaultIdentifierGenerator}(UUID.replace("-",""))
*/
ASSIGN_UUID(4),
/**
* @deprecated 3.3.0 please use {@link #ASSIGN_ID}
*/
ID_WORKER(3),
/**
* @deprecated 3.3.0 please use {@link #ASSIGN_ID}
*/
ID_WORKER_STR(3),
/**
* @deprecated 3.3.0 please use {@link #ASSIGN_UUID}
*/
UUID(4);
private final int key;
IdType(int key) {
this.key = key;
}
}
配置该应用中所有的实体类主键自增
xxxxxxxxxx
mybatis-plus
global-config
db-config
id-type auto
如果基础环境引入了数据库配置需要在微服务中配置数据源,但是有些微服务如网关不需要配置数据库,此时方法一是在子pom文件中排除引入的数据库依赖,方法二是在启动类上的
@SpringBootApplication
注解的exclude
属性配置排除DatasourceAutoConfiguration.class
方法1实现
在子pom文件中排除引入的数据库依赖
xxxxxxxxxx
<dependency>
<groupId>com.earl.mall</groupId>
<artifactId>mall-common</artifactId>
<version>0.0.1-SNAPSHOT</version>
<exclusions>
<exclusion>
<groupId>com.baomidou</groupId>
<artifactId>mybatis-plus-boot-starter</artifactId>
</exclusion>
</exclusions>
</dependency>
方法2实现
在启动类上的
@SpringBootApplication
注解的exclude
属性配置排除DatasourceAutoConfiguration.class
xxxxxxxxxx
exclude = {DataSourceAutoConfiguration.class}) (
public class MallGatewayApplication {
public static void main(String[] args) {
SpringApplication.run(MallGatewayApplication.class, args);
}
}
mybatisplus的逻辑删除是更新操作,根据id和对应的逻辑删除字段将逻辑存在的指定id的记录的逻辑删除字段改成逻辑删除
配置了逻辑删除查询操作也会自动变成查询满足条件且逻辑删除字段为逻辑未删除的记录
在对应模块的application.yml文件中配置逻辑删除字段值
和默认配置相同可省略
xxxxxxxxxx
#mybatisplus逻辑删除配置,这是统一的全局配置,该配置就默认配置,如果配置和默认配置相同可以不写
#从mybatisPlus3.3.0以后要配置logic-delete-field属性了,这里是3.2.0不需要配置
#logic-delete-field: flag # 全局逻辑删除的实体字段名(since 3.3.0,配置后可以忽略不配置步骤2)
logic-delete-value 1 # 逻辑已删除值(默认为 1)
logic-not-delete-value 0 # 逻辑未删除值(默认为 0)
配置逻辑删除组件ISqlInjector并注入IOC容器
从MybatisPlus3.1.1开始不再需要配置该ISqlInjector组件,即高版本可省略
xxxxxxxxxx
public class MyBatisPlusConfiguration{
public ISqlInjector sqlInjector(){
return new LogicSqlInjector();
}
}
在实体类的逻辑删除标识字段上添加@Tablelogic
注解
@Tablelogic
注解内部有两个属性value和delval,分别表示代表逻辑未删除的字面值和逻辑删除的字面值,该属性值的默认值都为空字符串,为空字符串会自动获取全局配置,全局配置没有使用默认配置,如果不为空字符串就会优先使用该注解的配置来确定哪些值表示逻辑删除和逻辑未删除
【@Tablelogic
注解】
xxxxxxxxxx
RetentionPolicy.RUNTIME) (
ElementType.FIELD) (
public @interface TableLogic {
/**
* 默认逻辑未删除值(该值可无、会自动获取全局配置)
*/
String value() default "";
/**
* 默认逻辑删除值(该值可无、会自动获取全局配置)
*/
String delval() default "";
}
【配置实例】
xxxxxxxxxx
value = "1",delval = "0") (
private Integer showStatus;
【执行的SQL语句】
xxxxxxxxxx
==> Preparing: UPDATE pms_category SET show_status=0 WHERE cat_id IN ( ? ) AND show_status=1
==> Parameters: 1432(Long)
<== Updates: 1
SpringBoot调整日志级别配置打印MyBatisPlus的SQL语句
在应用的application.yml中配置MyBatisPlus日志级别
这样就能打印dao包下的MyBatisPlus的SQL执行语句
xxxxxxxxxx
#将SpringBoot应用的com.earl.mall包下所有类的日志级别调整成DEBUG级别
logging
level
com.earl.mall debug
配置mp的分页插件
xxxxxxxxxx
//@EnableTransactionManagement开启事务
"com.earl.mall.product.dao")//指定Mapper接口的位置 (
public class MPPagePluginConfig {
//引入分页插件
public PaginationInterceptor paginationInterceptor(){
PaginationInterceptor paginationInterceptor = new PaginationInterceptor();
//设置请求页码大于最后一页的操作,true表示调回到首页,false表示继续请求,默认为false
paginationInterceptor.setOverflow(true);
//设置最大单页限制数量,默认为500条,设置为-1表示不受限制
paginationInterceptor.setLimit(1000);
return paginationInterceptor;
}
}
分页相关工具类
分页参数处理
xxxxxxxxxx
/**
* Copyright (c) 2016-2019 人人开源 All rights reserved.
*
* https://www.renren.io
*
* 版权所有,侵权必究!
*/
package com.earl.common.utils;
import com.baomidou.mybatisplus.core.metadata.IPage;
import com.baomidou.mybatisplus.core.metadata.OrderItem;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.earl.common.xss.SQLFilter;
import org.apache.commons.lang.StringUtils;
import java.util.Map;
/**
* 查询参数
*
* @author Mark sunlightcs@gmail.com
*/
public class Query<T> {
public IPage<T> getPage(Map<String, Object> params) {
return this.getPage(params, null, false);
}
public IPage<T> getPage(Map<String, Object> params, String defaultOrderField, boolean isAsc) {
//分页参数
long curPage = 1;
long limit = 10;
if(params.get(Constant.PAGE) != null){
curPage = Long.parseLong((String)params.get(Constant.PAGE));
}
if(params.get(Constant.LIMIT) != null){
limit = Long.parseLong((String)params.get(Constant.LIMIT));
}
//分页对象
Page<T> page = new Page<>(curPage, limit);
//分页参数
params.put(Constant.PAGE, page);
//排序字段
//防止SQL注入(因为sidx、order是通过拼接SQL实现排序的,会有SQL注入风险)
String orderField = SQLFilter.sqlInject((String)params.get(Constant.ORDER_FIELD));
String order = (String)params.get(Constant.ORDER);
//前端字段排序
if(StringUtils.isNotEmpty(orderField) && StringUtils.isNotEmpty(order)){
if(Constant.ASC.equalsIgnoreCase(order)) {
return page.addOrder(OrderItem.asc(orderField));
}else {
return page.addOrder(OrderItem.desc(orderField));
}
}
//没有排序字段,则不排序
if(StringUtils.isBlank(defaultOrderField)){
return page;
}
//默认排序
if(isAsc) {
page.addOrder(OrderItem.asc(defaultOrderField));
}else {
page.addOrder(OrderItem.desc(defaultOrderField));
}
return page;
}
}
Query中自定义工具类SQLFilter
xxxxxxxxxx
/**
* Copyright (c) 2016-2019 人人开源 All rights reserved.
*
* https://www.renren.io
*
* 版权所有,侵权必究!
*/
package com.earl.common.xss;
import com.earl.common.exceptions.RRException;
import org.apache.commons.lang.StringUtils;
/**
* SQL过滤
*
* @author Mark sunlightcs@gmail.com
*/
public class SQLFilter {
/**
* SQL注入过滤
* @param str 待验证的字符串
*/
public static String sqlInject(String str){
if(StringUtils.isBlank(str)){
return null;
}
//去掉'|"|;|\字符
str = StringUtils.replace(str, "'", "");
str = StringUtils.replace(str, "\"", "");
str = StringUtils.replace(str, ";", "");
str = StringUtils.replace(str, "\\", "");
//转换成小写
str = str.toLowerCase();
//非法字符
String[] keywords = {"master", "truncate", "insert", "select", "delete", "update", "declare", "alter", "drop"};
//判断是否包含非法字符
for(String keyword : keywords){
if(str.indexOf(keyword) != -1){
throw new RRException("包含非法字符");
}
}
return str;
}
}
涉及自定义异常类
xxxxxxxxxx
/**
* Copyright (c) 2016-2019 人人开源 All rights reserved.
*
* https://www.renren.io
*
* 版权所有,侵权必究!
*/
package com.earl.common.exceptions;
/**
* 自定义异常
*
* @author Mark sunlightcs@gmail.com
*/
public class RRException extends RuntimeException {
private static final long serialVersionUID = 1L;
private String msg;
private int code = 500;
public RRException(String msg) {
super(msg);
this.msg = msg;
}
public RRException(String msg, Throwable e) {
super(msg, e);
this.msg = msg;
}
public RRException(String msg, int code) {
super(msg);
this.msg = msg;
this.code = code;
}
public RRException(String msg, int code, Throwable e) {
super(msg, e);
this.msg = msg;
this.code = code;
}
public String getMsg() {
return msg;
}
public void setMsg(String msg) {
this.msg = msg;
}
public int getCode() {
return code;
}
public void setCode(int code) {
this.code = code;
}
}
分页数据封装工具类
xxxxxxxxxx
/**
* Copyright (c) 2016-2019 人人开源 All rights reserved.
*
* https://www.renren.io
*
* 版权所有,侵权必究!
*/
package com.earl.common.utils;
import com.baomidou.mybatisplus.core.metadata.IPage;
import java.io.Serializable;
import java.util.List;
/**
* 分页工具类
*
* @author Mark sunlightcs@gmail.com
*/
public class PageUtils implements Serializable {
private static final long serialVersionUID = 1L;
/**
* 总记录数
*/
private int totalCount;
/**
* 每页记录数
*/
private int pageSize;
/**
* 总页数
*/
private int totalPage;
/**
* 当前页数
*/
private int currPage;
/**
* 列表数据
*/
private List<?> list;
/**
* 分页
* @param list 列表数据
* @param totalCount 总记录数
* @param pageSize 每页记录数
* @param currPage 当前页数
*/
public PageUtils(List<?> list, int totalCount, int pageSize, int currPage) {
this.list = list;
this.totalCount = totalCount;
this.pageSize = pageSize;
this.currPage = currPage;
this.totalPage = (int)Math.ceil((double)totalCount/pageSize);
}
/**
* 分页
*/
public PageUtils(IPage<?> page) {
this.list = page.getRecords();
this.totalCount = (int)page.getTotal();
this.pageSize = (int)page.getSize();
this.currPage = (int)page.getCurrent();
this.totalPage = (int)page.getPages();
}
public int getTotalCount() {
return totalCount;
}
public void setTotalCount(int totalCount) {
this.totalCount = totalCount;
}
public int getPageSize() {
return pageSize;
}
public void setPageSize(int pageSize) {
this.pageSize = pageSize;
}
public int getTotalPage() {
return totalPage;
}
public void setTotalPage(int totalPage) {
this.totalPage = totalPage;
}
public int getCurrPage() {
return currPage;
}
public void setCurrPage(int currPage) {
this.currPage = currPage;
}
public List<?> getList() {
return list;
}
public void setList(List<?> list) {
this.list = list;
}
}
分页查询的使用方法
参数格式
不需要的比如排序字段、排序方式、key等属性可以不写
xxxxxxxxxx
{
page: 1,//当前页码
limit: 10,//每页记录数
sidx: 'id',//排序字段
order: 'asc/desc',//排序方式
key: '华为'//检索关键字
}
查询方式
【控制器方法】
xxxxxxxxxx
/**
* @param params
* @param catelogId
* @return {@link R }
* @描述 根据商品分类id查询属性分组 ,@RequestParam注解能获取到get请求请求路径中的参数并封装对对应名字的参数中,如果是Map,
* 会将参数封装到Map集合中
* @author Earl
* @version 1.0.0
* @创建日期 2024/02/29
* @since 1.0.0
*/
"/list/{catelogId}") (
//@RequiresPermissions("product:attrgroup:list")
public R list( Map<String, Object> params,
"catelogId") Long catelogId){ (
PageUtils page = attrGroupService.queryPage(params,catelogId);
return R.ok().put("page", page);
}
【分页查询代码】
xxxxxxxxxx
/**
* @param params
* @param catelogId
* @return {@link PageUtils }
* @描述 分页查询请求的参数中有一个key字段,key字段就是检索关键字,分页查询参数和key都是连接在请求url后的,
* 这个key是renren-generator封装在自动生成的前端列表组件中的搜索框的,因为这个搜索框只有一个,想要尽可能多的展示
* 数据,需要对该key进行模糊匹配
* 带搜索框的查询条件为select * from pms_attr_group where catelog_id=? and (attr_group_id=key or
* attr_group_name like %key%
* 即查询属性分组表中商品分类id为指定值且属性分组id为搜索框内容或者属性分组的名字模糊匹配搜素内容的属性分组
* Spring中有一个工具类StringUtils.isEmpty(str)方法能判断str是否空字符串,自5.3版本起,isEmpty(Object)已建议弃用,
* 使用hasLength(String)或hasText(String)替代。
* QueryWrapper的and方法可以接受函数式接口Consumer,自动传参QueryWrapper,可以在函数式接口中连续添加查询条件
* @author Earl
* @version 1.0.0
* @创建日期 2024/02/29
* @since 1.0.0
*/
public PageUtils queryPage(Map<String, Object> params, Long catelogId) {
QueryWrapper<AttrGroupEntity> wrapper = new QueryWrapper<AttrGroupEntity>();
String key = (String) params.get("key");
if (StringUtils.hasLength(key)) {
wrapper.and(obj->{
obj.eq("attr_group_id",key).
or().like("attr_group_name",key)
.or().like("descript",key)
;
});
}
if(catelogId!=0){
wrapper.eq("catelog_id", catelogId);
}
IPage<AttrGroupEntity> page = this.page(
new Query<AttrGroupEntity>().getPage(params),
wrapper
);
return new PageUtils(page);
}
响应结果格式
xxxxxxxxxx
{
"msg": "success",
"code": 0,
"page": {
"totalCount": 0,
"pageSize": 10,
"totalPage": 0,
"currPage": 1,
"list": [{
"attrGroupId": 0, //分组id
"attrGroupName": "string", //分组名
"catelogId": 0, //所属分类
"descript": "string", //描述
"icon": "string", //图标
"sort": 0 //排序
"catelogPath": [2,45,225] //分类完整路径
}]
}
}
将查询列表数据处理成自定义封装的列表数据
实例
xxxxxxxxxx
/**
* @param params
* @return {@link PageUtils }
* @描述 带条件分页查询所有属性和属性关联的属性分组和商品分类名称
* 从page中取出数据二次封装以后传递个PageUtils的list属性
* @author Earl
* @version 1.0.0
* @创建日期 2024/03/05
* @since 1.0.0
*/
public PageUtils queryPage(Map<String, Object> params,Long catelogId) {
String key = (String) params.get("key");
QueryWrapper<AttrEntity> wrapper = new QueryWrapper<>();
if (StringUtils.hasLength(key)){
wrapper.and(obj->{
obj.eq("attr_id",key).or().like("attr_name",key);
});
}
if(catelogId!=0){
wrapper.eq("catelog_id",catelogId);
}
IPage<AttrEntity> page = this.page(
new Query<AttrEntity>().getPage(params),
wrapper
);
PageUtils pageUtils = new PageUtils(page);
//将分页数据从page中取出来再加工
List<AttrListVo> responseVo = page.getRecords().stream().map(attrEntity -> {
AttrListVo attrListVo = new AttrListVo();
BeanUtils.copyProperties(attrEntity, attrListVo);
AttrAttrgroupRelationEntity relation = attrAttrgroupRelationService.getOne(new QueryWrapper<AttrAttrgroupRelationEntity>().eq("attr_id", attrEntity.getAttrId()));
if (relation != null && relation.getAttrGroupId() != null) {
attrListVo.setGroupName(attrGroupDao.selectById(relation.getAttrGroupId()).getAttrGroupName());
}
if (attrEntity.getCatelogId() != null) {
attrListVo.setCatelogName(categoryDao.selectById(attrEntity.getCatelogId()).getName());
}
return attrListVo;
}).collect(Collectors.toList());
pageUtils.setList(responseVo);
return pageUtils;
}
在配置类上使用注解@EnableTransactionManagement
开启事务
只有在配置类上使用了该注解才能在需要控制事务的方法上使用
@Transactional
注解控制事务
xxxxxxxxxx
//@EnableTransactionManagement开启事务
"com.earl.mall.product.dao")//指定Mapper接口的位置 (
public class MPPagePluginConfig {
//引入分页插件
public PaginationInterceptor paginationInterceptor(){
PaginationInterceptor paginationInterceptor = new PaginationInterceptor();
//设置请求页码大于最后一页的操作,true表示调回到首页,false表示继续请求,默认为false
paginationInterceptor.setOverflow(true);
//设置最大单页限制数量,默认为500条,设置为-1表示不受限制
paginationInterceptor.setLimit(1000);
return paginationInterceptor;
}
}
在目标方法上使用注解@Transactional
控制操作的事务
xxxxxxxxxx
public void updateRelatedData(BrandEntity brand) {
this.updateById(brand);
if(StringUtils.hasLength(brand.getName())){
categoryBrandRelationService.updateBrandNameByBrandId(brand.getBrandId(),brand.getName());
}
//TODO 品牌名称更新时更新相应的冗余数据
}
向Spring容器注入组件
代码实例
xxxxxxxxxx
/**
* @author Earl
* @version 1.0.0
* @描述 Mp的字段自动填充组件
* @创建日期 2024/03/27
* @since 1.0.0
*/
public class MyMetaObjectHandler implements MetaObjectHandler {
/**
* @param metaObject
* @描述 设置生成记录时需要自动填充的字段
* @author Earl
* @version 1.0.0
* @创建日期 2024/03/27
* @since 1.0.0
*/
public void insertFill(MetaObject metaObject) {
this.setFieldValByName("createTime", new Date(), metaObject);
this.setFieldValByName("updateTime", new Date(), metaObject);
}
/**
* @param metaObject
* @描述 设置更新记录时需要自动填充的字段
* @author Earl
* @version 1.0.0
* @创建日期 2024/03/27
* @since 1.0.0
*/
public void updateFill(MetaObject metaObject) {
this.setFieldValByName("updateTime", new Date(), metaObject);
}
}
在实体类指定字段上设置填充策略
配置实例
xxxxxxxxxx
"pms_spu_info") (
public class SpuInfoEntity implements Serializable {
private static final long serialVersionUID = 1L;
/**
* 商品id
*/
private Long id;
/**
* 商品名称
*/
private String spuName;
/**
* 商品描述
*/
private String spuDescription;
/**
* 所属分类id
*/
private Long catelogId;
/**
* 品牌id
*/
private Long brandId;
/**
* 商品重量
*/
private BigDecimal weight;
/**
* 上架状态[0 - 下架,1 - 上架]
*/
private Integer publishStatus;
/**
* 记录创建时间
*/
(fill = FieldFill.INSERT)
private Date createTime;
/**
* 记录更新时间
*/
(fill = FieldFill.INSERT_UPDATE)
private Date updateTime;
}
引入Junit依赖
非maven项目引入junit依赖需要在本地仓库找到junit.junit-4.12和org.hamcrest.hamcrest-core-1.3,同时导入才不会报错,同时需要添加测试目录才能生效
Junit依赖
junit的依赖已经被spring-boot-starter-test依赖了
xxxxxxxxxx
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
单元测试格式
注意,没有
@RunWith(SpringRunner.class)
老版本的SpringBoot涉及到自动注入的对象就会报空指针异常,新版本的SpringBoot单元测试没有这个注解
xxxxxxxxxx
SpringRunner.class)//指定使用Spring的驱动来跑单元测试,这是老版本SpringBoot的写法,新版本已经不这么写了 (
public class MallProductApplicationTests {
BrandService brandService;
public void contextLoads() {
BrandEntity brandEntity = new BrandEntity();
brandEntity.setName("华为");
System.out.println(brandService.save(brandEntity));
}
}
异常情况
在IDEA使用默认的SpringBoot初始化工具初始化的项目可能单元测试是以下结构
这种结构在更换SpringBoot和SpringCloud的版本后无法直接进行单元测试,需要添加注解
@RunWith(SpringRunner.class)
和将测试类和方法上添加public前缀
xxxxxxxxxx
class MallOrderApplicationTests {
void contextLoads() {
}
}
普通测试
在测试类上添加了@RunWith(SpringRunner.class)
注解和@SpringBootTest
注解的测试类是启动SpringBoot
项目并对项目中的组件进行测试,我们可以不添加@RunWith(SpringRunner.class)
注解和@SpringBootTest
注解而只使用Junit
的@Test
注解不用启动SpringBoot项目只做普通测试,示例代码如下
xxxxxxxxxx
public class MallProductApplicationTests {
public void contextLoads() {
BrandEntity brandEntity = new BrandEntity();
brandEntity.setName("华为");
}
}
使用Lombok需要添加对应的Lombok依赖,而且IDEA需要安装Lombok插件,作用是简化JavaBean开发
依赖导入
xxxxxxxxxx
<!--lombok-->
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<version>1.18.8</version>
</dependency>
API详解
@Data
:在程序编译的时候自动为标注了@Data注解的实体类的所有非final
字段添加@setter
、@AllArgsConstructor
、@NoArgsConstructor
注解、为所有字段添加@ToString
、@EqualsAndHashCode
注解、@Getter
注解
@TableName("pms_attr")
:标注当前实体类对应的数据库表名
@NoArgsConstructor
:为当前实体类填充无参构造方法
@AllArgsConstructor
:为当前实体类填充全参构造方法
@ToString
:重写当前实体类的toString方法
@Getter
:自动生成Getter方法
注意boolean
类型的字段使用Lombok
时getter
方法不再以get
打头,而是以is
打头
@Setter
:自动生成Setter方法
@Slf4j
:可以在标注的类下使用属性log
来记录日志
log.info("Exchange[{}]创建成功","hello-java-exchange")
还可以为当前标注类的日志专门指定统一的日志主题
用法综合示例
xxxxxxxxxx
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
import lombok.ToString;
//为属性自动填充getter和setter方法
//无参构造
//全参构造
//重写实体类的toString方法
public class Product {
private Long id;//商品唯一标识
private String title;//商品名称
private String category;//分类名称
private Double price;//商品价格
private String images;//图片地址
}
引入Logback依赖可以使用slf4j来打印日志,slf4j是接口,logback是该接口的一种实现
引入依赖
xxxxxxxxxx
<dependency>
<groupId>ch.qos.logback</groupId>
<artifactId>logback-classic</artifactId>
<version>1.2.3</version>
</dependency>
logback配置文件logback.xml
【类路径下】
xxxxxxxxxx
<configuration
xmlns="http://ch.qos.logback/xml/ns/logback"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://ch.qos.logback/xml/ns/logback logback.xsd">
<appender name="STDOUT" class="ch.qos.logback.core.ConsoleAppender">
<encoder>
<pattern>%date{HH:mm:ss} [%t] %logger - %m%n</pattern>
</encoder>
</appender>
<logger name="c" level="debug" additivity="false">
<appender-ref ref="STDOUT"/>
</logger>
<root level="ERROR">
<appender-ref ref="STDOUT"/>
</root>
</configuration>
常用API
@Slf4j(topic="c.Test2")
标注该注解类中产生的日志都会在线程信息后面紧跟注解中的topic属性值,一般用作日志的记录位置区分标识
log.debug("{}",task.get());
log对象的日志函数中字符串中的大括号叫占位符,占位符的数据来自于后面紧跟着的参数,可以使用多个占位符和多个参数,依次按顺序填充
内含包org.apache.httpcomponents
,是Apache用java代码实现的使用java代码发送HTTP请求的一个工具类
引入依赖
xxxxxxxxxx
<!--httpCore-->
<dependency>
<groupId>org.apache.httpcomponents</groupId>
<artifactId>httpcore</artifactId>
<version>4.4.12</version>
</dependency>
依赖httpcore
和httpclient
的服务端Http请求发起工具类
除了Httpcore
和HttpClient
,Spring
的RestTemplate
也能发起Http
请求
[HttpUtils
]
xxxxxxxxxx
package com.earl.mall.auth.utils;
import org.apache.commons.lang.StringUtils;
import org.apache.http.HttpResponse;
import org.apache.http.NameValuePair;
import org.apache.http.client.HttpClient;
import org.apache.http.client.entity.UrlEncodedFormEntity;
import org.apache.http.client.methods.HttpDelete;
import org.apache.http.client.methods.HttpGet;
import org.apache.http.client.methods.HttpPost;
import org.apache.http.client.methods.HttpPut;
import org.apache.http.conn.ClientConnectionManager;
import org.apache.http.conn.scheme.Scheme;
import org.apache.http.conn.scheme.SchemeRegistry;
import org.apache.http.conn.ssl.SSLSocketFactory;
import org.apache.http.entity.ByteArrayEntity;
import org.apache.http.entity.StringEntity;
import org.apache.http.impl.client.DefaultHttpClient;
import org.apache.http.message.BasicNameValuePair;
import javax.net.ssl.SSLContext;
import javax.net.ssl.TrustManager;
import javax.net.ssl.X509TrustManager;
import java.io.UnsupportedEncodingException;
import java.net.URLEncoder;
import java.security.KeyManagementException;
import java.security.NoSuchAlgorithmException;
import java.security.cert.X509Certificate;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
public class HttpUtils {
/**
* get
*
* @param host
* @param path
* @param method
* @param headers
* @param querys
* @return
* @throws Exception
*/
public static HttpResponse doGet(String host, String path, String method,
Map<String, String> headers,
Map<String, String> querys)
throws Exception {
HttpClient httpClient = wrapClient(host);
HttpGet request = new HttpGet(buildUrl(host, path, querys));
for (Map.Entry<String, String> e : headers.entrySet()) {
request.addHeader(e.getKey(), e.getValue());
}
return httpClient.execute(request);
}
/**
* post form
*
* @param host
* @param path
* @param method
* @param headers
* @param querys
* @param bodys
* @return
* @throws Exception
*/
public static HttpResponse doPost(String host, String path, String method,
Map<String, String> headers,
Map<String, String> querys,
Map<String, String> bodys)
throws Exception {
HttpClient httpClient = wrapClient(host);
HttpPost request = new HttpPost(buildUrl(host, path, querys));
for (Map.Entry<String, String> e : headers.entrySet()) {
request.addHeader(e.getKey(), e.getValue());
}
if (bodys != null) {
List<NameValuePair> nameValuePairList = new ArrayList<NameValuePair>();
for (String key : bodys.keySet()) {
nameValuePairList.add(new BasicNameValuePair(key, bodys.get(key)));
}
UrlEncodedFormEntity formEntity = new UrlEncodedFormEntity(nameValuePairList, "utf-8");
formEntity.setContentType("application/x-www-form-urlencoded; charset=UTF-8");
request.setEntity(formEntity);
}
return httpClient.execute(request);
}
/**
* Post String
*
* @param host
* @param path
* @param method
* @param headers
* @param querys
* @param body
* @return
* @throws Exception
*/
public static HttpResponse doPost(String host, String path, String method,
Map<String, String> headers,
Map<String, String> querys,
String body)
throws Exception {
HttpClient httpClient = wrapClient(host);
HttpPost request = new HttpPost(buildUrl(host, path, querys));
for (Map.Entry<String, String> e : headers.entrySet()) {
request.addHeader(e.getKey(), e.getValue());
}
if (StringUtils.isNotBlank(body)) {
request.setEntity(new StringEntity(body, "utf-8"));
}
return httpClient.execute(request);
}
/**
* Post stream
*
* @param host
* @param path
* @param method
* @param headers
* @param querys
* @param body
* @return
* @throws Exception
*/
public static HttpResponse doPost(String host, String path, String method,
Map<String, String> headers,
Map<String, String> querys,
byte[] body)
throws Exception {
HttpClient httpClient = wrapClient(host);
HttpPost request = new HttpPost(buildUrl(host, path, querys));
for (Map.Entry<String, String> e : headers.entrySet()) {
request.addHeader(e.getKey(), e.getValue());
}
if (body != null) {
request.setEntity(new ByteArrayEntity(body));
}
return httpClient.execute(request);
}
/**
* Put String
* @param host
* @param path
* @param method
* @param headers
* @param querys
* @param body
* @return
* @throws Exception
*/
public static HttpResponse doPut(String host, String path, String method,
Map<String, String> headers,
Map<String, String> querys,
String body)
throws Exception {
HttpClient httpClient = wrapClient(host);
HttpPut request = new HttpPut(buildUrl(host, path, querys));
for (Map.Entry<String, String> e : headers.entrySet()) {
request.addHeader(e.getKey(), e.getValue());
}
if (StringUtils.isNotBlank(body)) {
request.setEntity(new StringEntity(body, "utf-8"));
}
return httpClient.execute(request);
}
/**
* Put stream
* @param host
* @param path
* @param method
* @param headers
* @param querys
* @param body
* @return
* @throws Exception
*/
public static HttpResponse doPut(String host, String path, String method,
Map<String, String> headers,
Map<String, String> querys,
byte[] body)
throws Exception {
HttpClient httpClient = wrapClient(host);
HttpPut request = new HttpPut(buildUrl(host, path, querys));
for (Map.Entry<String, String> e : headers.entrySet()) {
request.addHeader(e.getKey(), e.getValue());
}
if (body != null) {
request.setEntity(new ByteArrayEntity(body));
}
return httpClient.execute(request);
}
/**
* Delete
*
* @param host
* @param path
* @param method
* @param headers
* @param querys
* @return
* @throws Exception
*/
public static HttpResponse doDelete(String host, String path, String method,
Map<String, String> headers,
Map<String, String> querys)
throws Exception {
HttpClient httpClient = wrapClient(host);
HttpDelete request = new HttpDelete(buildUrl(host, path, querys));
for (Map.Entry<String, String> e : headers.entrySet()) {
request.addHeader(e.getKey(), e.getValue());
}
return httpClient.execute(request);
}
private static String buildUrl(String host, String path, Map<String, String> querys) throws UnsupportedEncodingException {
StringBuilder sbUrl = new StringBuilder();
sbUrl.append(host);
if (!StringUtils.isBlank(path)) {
sbUrl.append(path);
}
if (null != querys) {
StringBuilder sbQuery = new StringBuilder();
for (Map.Entry<String, String> query : querys.entrySet()) {
if (0 < sbQuery.length()) {
sbQuery.append("&");
}
if (StringUtils.isBlank(query.getKey()) && !StringUtils.isBlank(query.getValue())) {
sbQuery.append(query.getValue());
}
if (!StringUtils.isBlank(query.getKey())) {
sbQuery.append(query.getKey());
if (!StringUtils.isBlank(query.getValue())) {
sbQuery.append("=");
sbQuery.append(URLEncoder.encode(query.getValue(), "utf-8"));
}
}
}
if (0 < sbQuery.length()) {
sbUrl.append("?").append(sbQuery);
}
}
return sbUrl.toString();
}
private static HttpClient wrapClient(String host) {
HttpClient httpClient = new DefaultHttpClient();
if (host.startsWith("https://")) {
sslClient(httpClient);
}
return httpClient;
}
private static void sslClient(HttpClient httpClient) {
try {
SSLContext ctx = SSLContext.getInstance("TLS");
X509TrustManager tm = new X509TrustManager() {
public X509Certificate[] getAcceptedIssuers() {
return null;
}
public void checkClientTrusted(X509Certificate[] xcs, String str) {
}
public void checkServerTrusted(X509Certificate[] xcs, String str) {
}
};
ctx.init(null, new TrustManager[] { tm }, null);
SSLSocketFactory ssf = new SSLSocketFactory(ctx);
ssf.setHostnameVerifier(SSLSocketFactory.ALLOW_ALL_HOSTNAME_VERIFIER);
ClientConnectionManager ccm = httpClient.getConnectionManager();
SchemeRegistry registry = ccm.getSchemeRegistry();
registry.register(new Scheme("https", 443, ssf));
} catch (KeyManagementException ex) {
throw new RuntimeException(ex);
} catch (NoSuchAlgorithmException ex) {
throw new RuntimeException(ex);
}
}
}
一般市面上商用的服务端发起Http请求的工具类都会依赖于httpcore
和httpclient
引入依赖
xxxxxxxxxx
<!--httpclient-->
<dependency>
<groupId>org.apache.httpcomponents</groupId>
<artifactId>httpclient</artifactId>
<version>4.5.9</version>
</dependency>
在SpringBoot中使用Servlet相关的东西【如ServletRequest】需要在项目中引入依赖servlet-api,但是tomcat自带了servlet-api依赖,将scope改为provided,表示目标环境已经存在
xxxxxxxxxx
<!--servlet-api-->
<dependency>
<groupId>javax.servlet</groupId>
<artifactId>servlet-api</artifactId>
<version>2.5</version>
<scope>provided</scope>
</dependency>
apache的Commons-lang
工具包,关注一下commons-lang
包和commons-lang3
包的不同
xxxxxxxxxx
<!--commons-lang-->
<dependency>
<groupId>commons-lang</groupId>
<artifactId>commons-lang</artifactId>
<version>2.6</version>
</dependency>
boolean ---> StringUtils.isNotBlank(String param)
功能解析:判断字符串param
是否为空串或者只是空白字符,如果入参只含有制表符、换行符、换页符和回车符,都会被识别为空白字符判false
,只要有一个字符不满足上述情况就会判true
使用示例:
xxxxxxxxxx
//判断空白字符
System.out.println(StringUtils.isNotBlank(" "));//false
//判断普通字符携带空白字符
System.out.println(StringUtils.isNotBlank("1 "));//true
//判断制表符、换行符、换页符和回车符
System.out.println(StringUtils.isNotBlank("\t \f \r \n "));//false
依赖引入
xxxxxxxxxx
<dependency>
<groupId>com.google.code.gson</groupId>
<artifactId>gson</artifactId>
<version>2.8.5</version>
</dependency>
validation-api
是javax旗下的,引入后可以在项目中使用JSR303规范的数据校验功能以及自定义校验注解;在SpringBoot2.3.x以前是随web-starter的hibernate-validator
一起引入的
依赖引入
xxxxxxxxxx
<!--validation-api-->
<dependency>
<groupId>javax.validation</groupId>
<artifactId>validation-api</artifactId>
<version>2.0.1.Final</version>
</dependency>
使用JSR303【Java Specification Requests,即Java规范提案】,JSR303规定了数据校验的相关标准;
SpringBoot从2.3.x版本开始其中不再内置校验了,从依赖关系上来看SpringBoot的web starter
引入了校验注解相关的依赖hibernate-validator
,包括javax.validation.contraints
包也是hibernate-validator
下的,高版本SpringBoot似乎不再包含该依赖了,注意甄别
用法
通过在实体类的属性上使用校验注解来对数据指定校验规则
此外在Controller
中要在参数列表中参数前面使用@Valid
注解进行标注,只在实体类上标注数据校验注解不在Controller
中对应位置标注@Valid
注解是不会主动对数据进行校验的
响应状态码是400
,提示Bad Request
,说明服务端数据校验是不通过的,校验错误信息封装在响应数据的errors
属性中,errors.defaultMessage
是校验错误信息、errors.field
是发生校验错误的属性、errors.rejectValue
是发生校验错误的属性值,但是这个校验错误信息返回格式不规范,实际开发中都需要专门封装成统一响应格式返回
多个校验注解可以一起使用
常见数据校验相关的注解
这些注解全部可以在包
javax.validation.contraints
下找到,具体含义看每个注解的注释,注释还规定了注解能放在哪些参数类型上
@Email
作用:被该注解标注的属性值必须是邮箱
@NotNull
作用:被该注解标注的属性值不能为null
补充说明:该注解可以标注在任意类型的属性上
@Future
作用:被该注解标注的属性值必须是未来时间
@Min()
作用:被该注解标注的属性值必须比value
属性的指定值大
配置实例:
xxxxxxxxxx
value = 0,message = "排序数字必须大于等于0") (
private Integer sort;
@Max
作用:被该注解标注的属性值必须比指定值小
@NotEmpty
作用:被该注解标注的属性值不能为null
或者空字符串,但是可以是空格字符串
补充说明:@NotEmpty
只支持放在字符串、集合、Map和数组类型的属性上
@Size
作用:被该注解标注的属性值必须满足长度要求
@NotBlank
作用:被该注解标注的属性值不能为null
、空字符串、空格字符串,字符串至少包含一个非空格字符
@Pattern
作用:自定义校验规则
补充说明:
该注解中有一个regexp
属性,需要写一个字符串正则表示式【注意正则表达式再Java中需要去掉两边的斜杠,经过本地测试有斜杠偶尔能成功,但是肯定会出问题】,通过正则表达式来指定自定义的校验规则;
message
属性仍然为校验错误的错误提示信息
该注解不支持Integer
类型进行正则表达式校验,报错500
@Pattern
注解仅支持有值情况下的正则表达式校验,值为null
或者空字符串的情况下默认是校验正确的,注意啊,因为空值情况下有专门的非空注解来进行校验,所以基本上注解是不会对空值情况还进行相应的校验,也就是会默认校验正确,因为一个实体类针对不同的操作比如新增和修改可能涉及到指定多组校验,此时某些字段修改时可能会提交修改也可能不会提交修改,此时分组内的校验规则没指定非空校验,此时就会默认触发对应有值情况下需要校验的规则返回为正确,这样能同时实现空值情况下不进行入参校验【或者说空值默认校验结果为真】,有值的情况下严格执行入参校验规则
配置实例:
xxxxxxxxxx
regexp = "/^[a-zA-Z]$/",message = "检索首字母必须是一个字母") (
private String firstLetter;
@Length
作用:限定入参长度,min
属性限定入参长度最小值,max
属性限定入参长度最大值
配置实例:
xxxxxxxxxx
/**
* 用户密码
*/
message = "密码必须是6-18位字符") (
min = 6,max = 18,message = "密码必须是6-18位字符") (
private String password;
第三方校验注解
@URL
作用:被该注解标注的属性值必须为一个url
,入参该参数为空会默认不进行校验
补充说明:这个注解是org.hibernate.validator.constraint
包提供的,是hibernate
对JSR303的额外实现,从依赖关系上来看SpringBoot的web starter
引入了校验注解相关的依赖hibernate-validator
,包括javax.validation.contraints
包也是hibernate-validator
下的,高版本SpringBoot似乎不再包含该依赖了,注意甄别
自定义返回服务端校验错误信息
每个校验规则中都有一个message属性,如果没有自定义message信息就会默认使用ValidationMessage.properties
文件中的对应校验错误的message消息,中文地区会使用ValidationMessage_zh_CN.properties
文件中的校验错误信息
对默认消息不满意就自己指定校验注解的message信息
自定义校验信息响应格式
在被校验的Bean参数后面紧跟BindingResult
类型的参数,SpringBoot会自动将校验结果封装到该对象中,
该bindingResult
对象的hasErrors
布尔类型属性中封装了本次校验的结果,如果为true表示校验失败,为false表示校验成功
可以从bindingResult
中获取到错误的信息,封装成一个Map
进行返回,校验错误信息封装见以下示例
注意写了bindingResult
,校验异常会被自动处理,将错误信息封装到bindingResult,这种情况是不会抛校验异常的;不写bindingResult
出现异常是会抛异常信息的,每个控制器方法中都写校验错误处理代码显得太冗余,使用全局异常专门处理校验异常能省去很多冗余代码
弹幕说会有重复key的问题,同一个属性上使用俩个验证的话,任何一项不满足,BindingResult
中会封装两个fieldError
对象,但是这两个对象的field
属性是相同的,但是defaultMessage
属性分别是两个校验注解校验错误的对应提示信息,封装到Map
中就会出现一个重复key
不同value
的情况
xxxxxxxxxx
"/save") (
public R save( BrandEntity brand, BindingResult bindingResult){
if(bindingResult.hasErrors()){
//1. 准备封装错误校验信息的容器
Map<String,String> bingErrors = new HashMap<>();
//2. 获取所有的错误校验结果并封装进Map
bindingResult.getFieldErrors().forEach(item->{
//获取校验错误的属性名字
String field = item.getField();
//获取对应错误属性的错误校验信息
String msg = item.getDefaultMessage();
bingErrors.put(field,msg);
});
return R.error(400,"提交的数据不合法").put("data",bingErrors);
}else{
brandService.save(brand);
}
return R.ok();
}
自定义校验处理全局统一处理
使用
@ControllerAdvice
+@ExceptionHandler
的方式定义全局的数据校验异常处理,注意有全局参数校验异常处理的前提下,在控制器方法中对异常进行了捕获是不会触发这个全局异常的,即数据校验个别方法还可以通过控制器方法捕获的方式进行个性化处理
实例
xxxxxxxxxx
//@ResponseBody
//@ControllerAdvice(basePackages = "com.earl.mall.product.controller")
basePackages = "com.earl.mall.product.controller") (
public class MallControllerExceptionHandler {
MethodArgumentNotValidException.class) (
public R handleValidException(MethodArgumentNotValidException e){
//特定异常类型可以通过发生异常后对应异常的getClass方法获取
log.error("数据校验错误:{},异常类型:{}",e.getMessage(),e.getClass());
//1. bindingResult可以通过e.getBindingResult()获取
BindingResult bindingResult = e.getBindingResult();
//1. 准备封装错误校验信息的容器
Map<String,String> bingErrors = new HashMap<>();
//2. 获取所有的错误校验结果并封装进Map
bindingResult.getFieldErrors().forEach(item->{
//获取错误属性名字
String field = item.getField();
//获取对应错误属性的错误校验信息
String msg = item.getDefaultMessage();
bingErrors.put(field,msg);
});
//StatusCode.VALID_EXCEPTION是自定义异常枚举类型
return R.error(StatusCode.VALID_EXCEPTION.getCode(), StatusCode.VALID_EXCEPTION.getMsg()).put("data",bingErrors);
}
}
校验注解分组
对于一个品牌实体类,新增品牌和修改品牌的参数很可能是不一样的,比如新增不需要携带品牌ID,但是修改必须要带品牌ID、新增品牌和修改品牌时品牌名都不能为空。但是此时实体类的校验规则只有一套,此时就需要使用JSR303分组校验功能
每一种校验注解都有一个groups
属性,group
属性是一个接口【Classs<?>
】数组,这个接口是自定义的接口,比如在包valid下创建两个接口AddGroup
和UpdateGroup
,这是两个空接口,标注在不同的校验注解中分别表示在新增的时候才调用新增的校验,修改的时候才调用修改的校验,只是作为一种校验组合的区分在控制器方法中进行区分
如果一个校验规则新增和修改都需要校验,则在group属性同时指定AddGroup
和UpdateGroup
两个接口
实例:
xxxxxxxxxx
/**
* 品牌
*
* @author Earl
* @email 18794830715@163.com
* @date 2024-01-27 08:45:26
*/
"pms_brand") (
public class BrandEntity implements Serializable {
private static final long serialVersionUID = 1L;
/**
* 品牌id
*/
(message = "品牌ID不能为空",groups = {UpdateGroup.class})
private Long brandId;
/**
* 品牌名
*/
(message = "必须填写品牌名",groups = {AddGroup.class,UpdateGroup.class})
private String name;
/**
* 品牌logo地址
*/
(message = "请输入合法的logo地址",groups = {AddGroup.class,UpdateGroup.class})
(groups = {AddGroup.class})
private String logo;
/**
* 介绍
*/
private String descript;
/**
* 显示状态[0-不显示;1-显示]
*/
(vals={0,1},groups = {AddGroup.class,UpdateGroup.class, UpdateSingleFieldGroup.class})
(groups = {AddGroup.class,UpdateGroup.class})
private Integer showStatus;
/**
* 检索首字母
*/
(groups = {AddGroup.class})
(regexp = "^[a-zA-Z]$",message = "检索首字母必须是一个字母",groups = {AddGroup.class,UpdateGroup.class})
private String firstLetter;
/**
* 排序
*/
(value = 0,message = "排序数字必须大于等于0",groups = {AddGroup.class,UpdateGroup.class})
(groups = {AddGroup.class})
private Integer sort;
}
分组校验的使用
制定好校验规则后在控制器方法中将原来validation中的校验注解换成spring框架提供的
@Validated
注解,该注解中的value
属性也是接口数组,即在其中指定校验分组,用来实现多场景情况下的复杂校验
🔎:@validated
如果指定了分组,那么Bean中只校验属于该分组注解标注的值是否合法,没有指定分组的注解不会进行校验,如果@validated
没有标注group,就会校验bean中所有没有分组的校验注解,此时被分组的注解反而不会生效
🔎:因为空值情况下有专门的非空注解来进行校验,所以基本上注解是不会对空值情况还进行相应的校验,也就是会默认校验正确,因为一个实体类针对不同的操作比如新增和修改可能涉及到指定多组校验,此时某些字段修改时可能会提交修改也可能不会提交修改,此时分组内的校验规则没指定非空校验,此时就会默认触发对应有值情况下需要校验的规则返回为正确,这样能同时实现空值情况下不进行入参校验【或者说空值默认校验结果为真】,有值的情况下严格执行入参校验规则
实例:
xxxxxxxxxx
"/save") (
//@RequiresPermissions("product:brand:save")
public R save( (AddGroup.class) BrandEntity brand){
brandService.save(brand);
return R.ok();
}
"/update") (
//@RequiresPermissions("product:brand:update")
public R update( (UpdateGroup.class) BrandEntity brand){
brandService.updateById(brand);
return R.ok();
}
现有的校验注解可能无法满足需求,比如校验排序字段必须为非负整数,此时能想到使用
@Pattern
注解使用正则表达式对字段进行校验,但是该字段类型为Integer类型,@Pattern
注解不能使用在Integer类型上【正则只能校验字符串】,此时就需要考虑使用自定义校验注解了自定义校验的实现需要三步:编写一个自定义校验注解,编写一个自定义检验器,关联自定义校验器和自定义校验注解
自定义校验注解要求
一个自定义校验注解必须满足JSR303规范,必须包含3个属性message
、groups
、payload
message
是校验出错以后的默认提示消息
groups
是注解必须支持分组校验功能
payload
是自定义校验注解还可以自定义一些负载信息
自定义校验注解必须标注指定的元信息数据[标注指定的注解并配置源信息数据]
@Target({METHOD,FIELD,ANNOTATION_TYPE,CONSTRACTOR,PARAMTER,TYPE_USE})
@Target
注解指定该注解可以标注的位置
@Retention(RUNTIME)
@Retention(RUNTIME)
指定该注解运行时可被获取
Constraint(validatedBy={})
Constraint(validatedBy={})
指定该注解关联的校验器,这个地方不指定就需要在系统初始化的时候进行指定
@Repeatable(List.calss)
@Repeatable(List.calss)
表示该注解是一个可重复注解
@Documented
自定义校验注解实例
自定义校验注解
xxxxxxxxxx
// validatedBy要指定一个ConstraintValidator的子类数组,我们可以指定自定义校验器,
// 以自定义校验器ListValueConstraintValidator为例
// 一个校验器只能适配一种参数类型,如果还需要适配其他参数类型,需要再定义一个校验器,并在同一个自定义校验注解使用validatedBy属性
// 进行多个校验器的关联,校验注解会自动根据注解标注参数的类型自动地选出对应类型的校验器进行校验
(
validatedBy = {ListValueConstraintValidator.class}
)
ElementType.METHOD, ElementType.FIELD, ElementType.ANNOTATION_TYPE, ElementType.CONSTRUCTOR, ElementType.PARAMETER, ElementType.TYPE_USE}) ({
RetentionPolicy.RUNTIME) (
//可重复注解,ListValue.List是校验注解内部自定义的可重复注解容器
ListValue.List.class) (
public @interface ListValue {
//JSR303规范中message消息都统一在ValidationMessages.properties
//我们也可以创建一个和该文件名一样的文件,在其中写对应的消息,Spring找不到会自动到自定义的同名文件中查找
//消息的属性名一般都使用注解全类名.message
String message() default "{com.earl.common.validate.annotation.ListValue.message}";
Class<?>[] groups() default {};
Class<? extends Payload>[] payload() default {};
int[] vals() default {};
//java8新特性,定义可重复注解,一个注解可能对多种情况进行分组标注,可能使用多个相同注解,这是校验注解的重复注解定义
ElementType.METHOD, ElementType.FIELD, ElementType.ANNOTATION_TYPE, ElementType.CONSTRUCTOR, ElementType.PARAMETER, ElementType.TYPE_USE}) ({
RetentionPolicy.RUNTIME) (
public @interface List {
ListValue[] value();
}
}
创建ValidationMessages.properties
同名文件并配置默认消息提示
这个属性配置文件
properties
文件可能中文读取会乱码,这个地方需要修改IDEA的File--Setting--Editor--File Encodings--将Properties Files中的Transparent native-to-ascii conversion勾选上并重新创建文件【最好将File Encodings中的所有编码格式都改成UTF-8】
xxxxxxxxxx
com.earl.common.validate.annotation.ListValue.message=必须提交指定的值
自定义校验器
xxxxxxxxxx
/**
* @author Earl
* @version 1.0.0
* @描述
* 自定义注解需要实现ConstraintValidator接口
* ConstraintValidator<A extends Annotation, T>有两个泛型,第一个泛型是关联的注解@ListValue,第二个
* 泛型是@ListValue注解能标注的地方,该接口中有两个抽象方法
* @创建日期 2024/02/28
* @since 1.0.0
*/
public class ListValueConstraintValidator implements ConstraintValidator<ListValue,Integer> {
private Set<Integer> set=new HashSet<>();
/**
* @param constraintAnnotation
* @描述 该方法能获取到ListValue注解中的属性值,该属性值能在isValid中对数据进行校验,遍历vals中的值封装到set集合中供
* initialize方法对实际传参进行判断
* @author Earl
* @version 1.0.0
* @创建日期 2024/02/28
* @since 1.0.0
*/
public void initialize(ListValue constraintAnnotation) {
int[] vals = constraintAnnotation.vals();
for (int val:vals) {
set.add(val);
}
}
/**
* @param value 要检验的实际入参
* @param context
* @return boolean
* @描述 判断是否校验成功
* @author Earl
* @version 1.0.0
* @创建日期 2024/02/28
* @since 1.0.0
*/
public boolean isValid(Integer value, ConstraintValidatorContext context) {
return set.contains(value);
}
}
业务中ES检索请求的处理逻辑是前端发起检索请求给后端Java服务器,Java服务器向ES服务器发起检索请求获取数据并响应给前端,Java客户端操作ES的方式有两种
第一种方式是使用spring-data-elasticsearch:transport-api.jar
通过ES的TCP端口9300,也即节点间的通信端口;这种方式SpringBoot版本不同,对应的transport-api.jar
也不同,更换ES的版本就要更换对应的transport-api.jar
和SpringBoot的版本,而且ES版本对应的transport-api.jar
根本就没出或者SpringBoot压根还没整合,这样不好;其次7.x版本已经不建议使用transport-api.jar
,8以后就直接准备废弃了通过9300端口操作ES的jar包
第二种方式是通过HTTP协议走9200端口发送请求操作ES,市面上通过这种方式操作ES的产品有
JestClient:非官方,更新慢,从maven仓库可以查询到最近版本的更新时间,比较慢,落后ES好几个小版本
RestTemplate:这个产品只是模拟发送HTTP请求,ES很多操作需要自己进行封装,封装起来很麻烦
HttpClient:该产品也只是模拟发送HTTP请求,ES的相关请求和响应数据处理需要自己封装,很麻烦;像这些只能用来发送HTTP请求的如OKHTTP等等都可以操作ES,但是DSL语句和响应结果需要自己封装工具进行处理
Elasticsearch-Rest-Client:官方RestClient,封装了ES操作,API层次比较分明,官方的ES发布到哪个版本,这个工具也会同时更新相应的版本,本项目就使用该客户端
据说有个开源的ebatis,用起来也非常爽
Elasticsearch-Rest-Client的官方文档在ES的Docs中的Elasticsearch Clients章节,里面列举了各种语言对ES的操作API,其中还有JavaScript客户端,但是ES一般属于后台服务器集群中一部分,一般不直接对外暴露,暴露可能会被公网恶意利用;使用js操作也不需要使用ES官方的工具,直接用js发送请求即可;Java API是基于9300端口操作ES的【而且文档标记7.0版本已经过时,在8.0版本将移除,在文档中推荐使用Java High Level REST Client,Java High Level REST Client是Java REST Client中两个工具的一种,还有一种是Java Low Level REST Client,两者的关系相当于mybatisJava Client
了】,Java REST Client是基于9200端口操作ES的
❓:为什么不用js发送查询请求,由nginx进行转发呢,还是因为安全的原因吗?反正就是用后端服务器调用来查询,以后再去看实际的情况
创建一个单独的模块mall-search
来使用Elasticsearch-Rest-Client中的Java High Level REST Client来操作ES服务器集群
搭建操作ES的模块
1️⃣:创建模块mall-search
,勾选整合Web中的Spring Web
说明:NoSQL中有个Spring Data Elasticsearch因为最新只整合到6.3版本的ES【当时ES的最新版本是7.4】,所以就不考虑SpringData Elasticsearch,如果ES使用的版本不是那么新,选择SpringData Elasticsearch其实也是很好的选择,相比于官方的Elasticsearch-Rest-Client做了更简化的封装
2️⃣:导入Java High Level REST Client
的maven依赖,将版本号改为对应ES服务器的版本号,将ES服务器的版本号在properties标签中进行重新指定
注意通过右侧的maven依赖树能够看到elasticsearch-rest-high-level-client虽然版本是7.4.2,但是子依赖中的部分版本还是6.8.5,这是因为SpringBoot对ES的版本进行了默认仲裁,SpringBoot2.2.2.RELEASE当引入SpringData Elasticsearch会自动仲裁Elasticsearch的版本为6.8.5【点开父依赖中的spring-boot-starter-parent
的父依赖的spring-boot-dependencies
能够看见相关的版本信息】
xxxxxxxxxx
<!--导入es的rest-high-level-client-->
<dependency>
<groupId>org.elasticsearch.client</groupId>
<artifactId>elasticsearch-rest-high-level-client</artifactId>
<version>7.4.2</version>
</dependency>
更改SpringBoot对Elasticsearch的版本自动仲裁,刷新maven直到依赖树中的相关依赖版本全部变成7.4.2
xxxxxxxxxx
<properties>
<elasticsearch.version>7.4.2</elasticsearch.version>
</properties>
3️⃣:对rest-high-level-client
进行配置
🔎:如果使用SpringData Elasticsearch对ES操作,配置就非常简单,这个在ES的整合SpringData Elasticsearch中已经实现了,这里要配置我们自己选择的rest-high-level-client
会稍微复杂一些
编写配置类MallElasticsearchConfig
并注入IoC容器,这个配置类参考ES的官方文档Java High Level REST Client中的Getting started中的Initialization
需要创建一个RestHighLevelClient
实例client
,通过该实例来创建ES的操作对象
【单节点集群的创建客户端实例】
xxxxxxxxxx
/**
* @author Earl
* @version 1.0.0
* @描述 对Java High Level REST Client进行配置,配置ES操作对象
* @创建日期 2024/05/24
* @since 1.0.0
*/
public class MallElasticSearchConfig {
/**
* @return {@link RestHighLevelClient }
* @描述 通过单节点集群的ip地址和端口以及通信协议名称来创建RestHighLevelClient对象
* @author Earl
* @version 1.0.0
* @创建日期 2024/05/24
* @since 1.0.0
*/
public RestHighLevelClient esRESTClient(){
RestHighLevelClient client = new RestHighLevelClient(
RestClient.builder(new HttpHost("192.168.56.10", 9200, "http"))
);
return client;
}
}
【多节点集群下的创建客户端实例】
多节点集群就在RestClient.builder(HttpHost...)
方法中的可变长度参数列表中输入各个节点的IP信息
xxxxxxxxxx
public RestHighLevelClient esRESTClient(){
RestHighLevelClient client = new RestHighLevelClient(
RestClient.builder(
new HttpHost("localhost", 9200, "http"),
new HttpHost("localhost", 9201, "http")
)
);
return client;
}
4️⃣:导入模块mall-common
引入注册中心【这里面引入的其他依赖挺多的,包含mp、Lombok、HttpCore、数据校验、Servlet API等】,配置配置中心、注册中心,服务名称在主启动类上使用注解@EnableDiscoveryClient
开启服务的注册发现功能,在主启动类使用@SpringBootApplication(exclude = DataSourceAutoConfiguration.class)
排除数据源
【配置中心bootstrap.properties
配置】
注意bootstrap.properties
文件必须在引入nacos的配置中心依赖后才会展示出小叶子图标
xxxxxxxxxx
spring.application.name=mall-stock
spring.cloud.nacos.config.server-addr=127.0.0.1:8848
spring.cloud.nacos.config.namespace=9c29064b-64f8-4a43-9375-eceb6e3c7957
5️⃣:编写测试类检查ES操作对象是否创建成功
只要能打印出client对象,说明成功连接并创建ES操作对象,后续只需要参考官方文档使用对应的API即可,对应的文档也在Java High Level REST Client中的所有APIs部分
xxxxxxxxxx
SpringRunner.class)//指定使用Spring的驱动来跑单元测试,这是老版本SpringBoot的写法,新版本已经不这么写了 (
public class MallSearchApplicationTests {
private RestHighLevelClient esRESTClient;
public void contextLoads() {
System.out.println(esRESTClient);//org.elasticsearch.client.RestHighLevelClient@3c9c6245
}
}
引入依赖
xxxxxxxxxx
<dependency>
<groupId>com.alibaba</groupId>
<artifactId>fastjson</artifactId>
<version>1.2.47</version>
</dependency>
🔎:注意nacos注册中心spring-cloud-alibaba-nacos-discovery
的父依赖nacos-client
的父依赖nacos-api
的父依赖中自带fastjson
xxxxxxxxxx
<!--nacos注册中心-->
<dependency>
<groupId>com.alibaba.cloud</groupId>
<artifactId>spring-cloud-alibaba-nacos-discovery</artifactId>
</dependency>
String--->JSON.toJSONString(Object object)
功能解析:将对象object
转换为json
格式字符串
使用示例:String userJSONStr = JSON.toJSONString(user);
示例含义:将user对象转换为json格式字符串
T--->JSON.paseObject(String jsonStr,Class T.class)
功能解析:将Json格式字符串jsonStr转换为指定对象T
使用示例:``
示例含义:将商品文档json数据转换为Product对象
使用fastjson的TypeReference指定要将Map类型的键值对转换为指定的数据类型,
原理是先将Map转换成json,再用fastjson将json字符串转成通过泛型指定的实体类
这种用法一般用在如服务调用,调用方拿到数据默认会将属性对应的json对象【包含多个属性】转换成LinkedHashMap,这是因为json格式的k-v数据天然符合Map类型的数据组织形式,默认转换成Map方便数据的读取,但是这样就无法将Map类型的数据强转为我们的目标类型如To类,这时候就可以使用fastjson的TypeReference来将Map转成json,再将json转成我们指定的目标数据类型
【响应类封装转换数据类型的fastjson的TypeReference】
xxxxxxxxxx
public class R extends HashMap<String, Object> {
private static final long serialVersionUID = 1L;
//使用泛型需要声明泛型,方法中使用的泛型在方法名前面声明<T> T
public <T> T getData(TypeReference<T> typeReference){
//接收到的Object类型里面的对象被自动反序列化成Map了,因为互联网传输过程中使用JSON天然符合Map特性
//系统底层默认转成Map是为了更方便数据的读取,R里面data存的数据的数据类型默认是LinkedMap类型的,LinkedHashMap无法被强转为我们自定义的To类
//需要使用fastjson的TypeReference先将Map转换成json,再用fastjson将json字符串转成通过泛型指定的实体类
Object data =get("data");
String dataJSONStr = JSON.toJSONString(data);
T t = JSON.parseObject(dataJSONStr, typeReference);
return t;
}
public R setData(Object data){
put("data",data);
return this;
}
public R() {
put("code", 0);
put("msg", "success");
}
public static R error() {
return error(HttpStatus.SC_INTERNAL_SERVER_ERROR, "未知异常,请联系管理员");
}
public static R error(String msg) {
return error(HttpStatus.SC_INTERNAL_SERVER_ERROR, msg);
}
public static R error(int code, String msg) {
R r = new R();
r.put("code", code);
r.put("msg", msg);
return r;
}
public static R ok(String msg) {
R r = new R();
r.put("msg", msg);
return r;
}
public static R ok(Map<String, Object> map) {
R r = new R();
r.putAll(map);
return r;
}
public static R ok() {
return new R();
}
public R put(String key, Object value) {
super.put(key, value);
return this;
}
/**
* @return {@link Integer }
* @描述 获取响应的响应码判断响应状态
* @author Earl
* @version 1.0.0
* @创建日期 2024/03/26
* @since 1.0.0
*/
public Integer getCode(){
return (Integer) this.get("code");
}
}
【在调用者解析并将数据转换为指定类型】
xxxxxxxxxx
Map<Long, Boolean> stockStatus = null;
try{
//远程服务调用获取数据并将数据利用fastjson的TypeReference转换成指定目标数据类型List<SkuStockExistTo>
List<SkuStockExistTo> skuStockExistTos = stockFeignClient.isStockExist(skuIds).getData(new TypeReference<List<SkuStockExistTo>>() {
});
//将该集合skuStockExistTos转成Map准备属性对拷
stockStatus = skuStockExistTos.stream().collect(Collectors.toMap(SkuStockExistTo::getSkuId, SkuStockExistTo::getIsExist));
}catch (Exception e){
log.error("远程调用库存服务异常,原因:{}",e);
}
引入依赖
引入以后修改文件还是需要重新编译整个项目或者重新编译当前资源【Ctrl+Shift+F9】才能在前端看到对应效果,据说使用jrebel插件可以更高效地大道热部署效果
如果是配置更改推荐还是重启项目,避免出现各种问题
xxxxxxxxxx
<!--不重启服务器实现代码动态更新dev-tools,本质是修改代码自动触发重新启动-->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-devtools</artifactId>
<optional>true</optional>
</dependency>
集中修改首页中对静态资源的超链接方法[给所有静态资源的uri添加前缀static],这个要根据具体情况具体分析啊,链接的写法很多样,比如很多喜欢以当前路径开始,此时这么修改就是错误的,这里只是展示所有的链接标签,实际修改要根据链接的情况来确定
href="
替换为href="/static
,
<script src="
替换为<script src="static/
,
<img src="
替换为<img src="static/
<src="index
替换为<src="static/index
注意如果属性值是常量字符串,比如action="/registry"
,此时使用Thymeleaf
来处理该属性可能会报错,比如th:action="/registry"
此时后端就会报错Thymeleaf
渲染出错
通过Thymeleaf
来从error
这个Map中获取错误校验信息,在没有发生校验错误的情况下error会为null,此时仍然从error中获取错误校验信息就会出现空指针异常,只有在error不为null的情况下才去执行从error中获取对应参数的错误校验信息
注意即使Map
类型的error
不为null
,但是Map中没有指定的属性如username
,此时仍然使用error.get("username")
,Thymeleaf
仍然会报错,即Map
不包含指定key
的数据但是仍然进行取值Thymeleaf
会直接报错,我们还需要通过Thymeleaf
对Map
处理的API#maps.containsKey(map,key)
来判断Map
类型的error
中是否包含key
为username
的数据,包含才进行取值,不包含就不取值
在商品模块引入Thymeleaf依赖做首页渲染
pom.xml
xxxxxxxxxx
<!--模板引擎Thymeleaf-->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-thymeleaf</artifactId>
</dependency>
将首页用到的静态资源目录index拷贝到类目录下的static目录下,将首页模板页面放在类路径目录template
目录下
注意没有引入Thymeleaf依赖Template目录下的资源是无法直接通过文件名访问到,即Thymeleaf没有配置默认前缀classpath:/templates/
,静态资源还是必须放在SpringBoot默认配置的静态资源目录下才能访问
Thymeleaf相关配置
在application.yml
使用下列配置关闭Thymeleaf的缓存
关闭Thymeleaf缓存,这样开发期间就能看见实时的更改效果
Thymeleaf的前缀默认配置spring.thymeleaf.prefix="classpath:/templates/"
后缀默认配置是spring.thymeleaf.suffix=".html"
xxxxxxxxxx
Spring
thymeleaf
cache false #关闭Thymeleaf缓存,这样开发期间就能看见实时的更改效果
在项目包com.earl.product
目录下创建web
包用来专门放商城前台的控制器方法
静态资源目录
静态资源放在默认的static
目录下可以通过路径如http://127.0.0.1:9000/index/css/GL.css
直接访问【访问不到可能是target目录没有载入】,静态页面模板index.html
放在template
目录下此时可以直接通过http://127.0.0.1:9000
直接访问,注意默认不能通过http://127.0.0.1:9000/index.html
访问【默认情况下没有做对应URI为index.html的映射】
注意项目里的前端静态资源统一加了static
前缀,即http://127.0.0.1:10000/static/index/css/GL.css
,此时对应的静态资源需要放在目录static/static
下,使用下列配置让SpringBoot忽略static
前缀,这样将静态资源放在static
目录下即可访问
xxxxxxxxxx
Spring
thymeleaf
cache false #关闭Thymeleaf缓存,这样开发期间就能看见实时的更改效果
mvc
static-path-pattern /static/** #这里前端所有的静态资源路径加了static前缀,使用该配置让SpringBoot处理过程中去掉该前缀,这样仍然将index目录放在static目录下即可,而不需要放在static/static目录下
业务逻辑演示
设置URI路径数组跳转的首页视图
引入Thymeleaf
就是要做视图渲染的,因为才需要控制器方法来获取数据并存入视图,如果只是访问页面不需要视图数据渲染,不需要控制器方法也能根据路径匹配到对应的index.html
注意使用Thymeleaf
渲染页面,控制器方法要响应对应的页面,不能在控制器方法上加@ResponseBody
注解,这样会直接导致响应对象
❓:注意一下这里有点问题,在不配置控制器方法的情况下,浏览器只能通过search.earlmall.com/
直接访问到templates/index.html
,竟然连search.earlmall.com/index.html
都访问不到,而且将index.html
转移到SpringBoot
的静态文件默认路径static
下也不行,真让人摸不着头脑,复习SpringBoot
的时候注意一下这个地方
xxxxxxxxxx
public class IndexController {
/**
* @return {@link String }
* @描述 匹配uri为"/"和"/index.html"都跳转首页视图
* 1. Thymeleaf的前缀默认配置spring.thymeleaf.prefix="classpath:/templates/"
* 2. 后缀默认配置是spring.thymeleaf.suffix=".html"
* 3. 返回视图地址,视图解析器会自动对视图地址进行拼串 前缀+返回值+后缀 即视图地址
* @author Earl
* @version 1.0.0
* @创建日期 2024/06/03
* @since 1.0.0
*/
"/","/index.html"}) ({
public String urisToIndexPage(){
return "index";
}
}
跳转页面后需要查询到所有商品的一级分类。模板中数据是写死的,使用ModelAndView
来缓存数据并从视图中取出相应的数据
从表pms_category
中查询出所有一级分类商品,特征是字段cat_level
字段属性值为1
使用Thymeleaf
从ModelAndView
中获取数据渲染到视图中需要使用Thymeleaf的语法,Thymeleaf官方文档-英文,点击Using Thymeleaf
下的链接可以下载对应版本的说明文档,包括PDF
、EPUB
、MOBI
等等版本
使用Thymeleaf
的优点是渲染以html为后缀的文件,浏览器可以直接打开,和前端沟通起来成本小,使用JSP浏览器打不开且前端不好做优化
【获取一级分类数据并存入ModelAndView
】
model.addAttribute("firstLevelCategories",firstLevelCategories);
需要指明变量的名称,否则只有打断点才知道对应变量的名称
xxxxxxxxxx
public class IndexController {
private CategoryService categoryService;
/**
* @return {@link String }
* @描述 匹配uri为"/"和"/index.html"都跳转首页视图
* 1. Thymeleaf的前缀默认配置spring.thymeleaf.prefix="classpath:/templates/"
* 2. 后缀默认配置是spring.thymeleaf.suffix=".html"
* 3. 返回视图地址,视图解析器会自动对视图地址进行拼串 前缀+返回值+后缀 即视图地址
* 4. 在跳转首页的过程中将数据查询出来放在ModelAndView中等待渲染
* @author Earl
* @version 1.0.0
* @创建日期 2024/06/03
* @since 1.0.0
*/
"/","/index.html"}) ({
public String urisToIndexPage(Model model){
List<CategoryEntity> firstLevelCategories=categoryService.getAllFirstLevelCategory();
model.addAttribute("firstLevelCategories",firstLevelCategories);
return "index";
}
}
【使用Thymeleaf语法需要在渲染视图引入Thymeleaf的名称空间xmlns:th="http://www.thymeleaf.org"
】
注意:<!DOCTYPE html>
是H5的标头
xxxxxxxxxx
<html lang="en" xmlns:th="http://www.thymeleaf.org">
...
</html>
【获取变量并渲染成标签的文本内容】
th:text="${}"
表示获取变量并将其渲染成文本填充到当前标签
xxxxxxxxxx
<div th:text="${}">
</div>
【表格遍历语法】
<tr th:each="prod : ${prods}">
的作用是循环遍历指定元素prods
,并根据元素集合中元素的个数决定循环创建多少个当前tr标签及其子标签,${prods}
是要遍历的元素,prod
是当前元素,使用th:text
来展示当前元素的各个属性变量【如果标签已经有文本,会使用当前变量值直接进行替换】,th:each
表示有多少个子元素就会生成多少个tr
标签和其子标签,这个标签也可以是其他html标签
xxxxxxxxxx
<table>
<tr>
<th>NAME</th>
<th>PRICE</th>
<th>IN STOCK</th>
</tr>
<tr th:each="prod : ${prods}">
<td th:text="${prod.name}">Onions</td>
<td th:text="${prod.price}">2.41</td>
<td th:text="${prod.inStock}? #{true} : #{false}">yes</td>
</tr>
</table>
使用Thymeleaf渲染商品一级分类列表
Thymeleaf自定义属性th:attr="ctg-data=${category.catId}
,渲染后的展示效果是ctg-data=商品分类id
,该属性是用来查询该分类id下的二三级商品分类的
xxxxxxxxxx
<ul>
<li th:each="category : ${firstLevelCategories}">
<a href="#" class="header_main_left_a" th:attr="ctg-data=${category.catId}">
<b th:text="${category.name}"></b>
</a>
</li>
</ul>
获取请求路径中的参数并渲染到页面中
${param.keyword}
中param
表示请求路径中的所有参数,param.keyword
表示从所有请求路径的参数中获取keyword
这个参数的参数值,如果该参数没有参数则显示placeholder
属性的值
xxxxxxxxxx
<div class="header_form">
<input type="text" id="keyword_input" placeholder="手机" th:value="${param.keyword}"/>
<a href="javascript:specifySearchParamKeyword()">搜索</a>
</div>
Thymeleaf
在字符串中拼接变量的写法th:href="|http://item.earlmall.com/${product.skuId}.html|"
即用两个竖线将字符串框起来
Thymeleaf
的#lists.contains(list,elements)
能判断list集合中是够含有某个元素
Thymeleaf
提供对两个数字之间的所有整数进行遍历的numbers.sequence
函数,使用方法是th:each="i:${#numbers.sequence(1,totalPages)}"
,作用是对数字1
和总页数totalPages
之间的所有整数进行遍历,i
是每次取出的整数
Thymeleaf
的Numbers
章节的Formatting decimal Numbers
展示了格式化api,其中${#numbers.formatDecimal(num,3,2)}
表示num整数位保留3位,小数位保留2位,整数位超出3位也能正常显示
th:each="val:${#strings.listSplit(String str,',')}"
将字符串用逗号分隔返回字符串片段数组
th:if="${!#strings.isEmpty(skuImage.imgUrl)}"
,其中的#strings.isEmpty(skuImage.imgUrl)
是判断字符串是否为空值
注意Thymeleaf
可以直接使用${session.loginUser}
从HttpServletSession
中获取指定key
的value
,使用了SpringSession
也可以直接取出来
th:else
配置th:if
使用能根据逻辑选择要展示的标签,但是Thymeleaf
中没有th:else
标签,我们可以通过th:if
标签取非来实现,也可以通过标签th:unless
来实现,标签th:unless
的直接用法是当判断条件为false
的时候显示所在组件,相应的含义是除非...才不,这个逻辑有点拗口,反正就当th:else
用,具体可以参考下面的例子
xxxxxxxxxx
<block th:fragment="list_cmd(id,btns)">
<div th:id="${id ?: 'toolbar1'}">
<div class="layui-btn-container">
<!--/* btns不为空时显示 */-->
<block th:if="${btns}" >
<th:block th:replace="${btns}" />
</block>
<!--/* btns为空时显示(除非btns不为空才不显示) */-->
<block th:unless="${btns}" >
<div class="layui-inline">
<th:block th:include="::btnAdd" />
</div>
<div class="layui-inline">
<th:block th:include="::btnDel" />
</div>
</block>
</div>
</div>
</block>
Thymeleaf
的名称空间IDEA
可以直接通过快捷字符thy
来创建
安装redis
以及构建集群见Linux指南中的安装redis
部分
SpringBoot配置
引入场景启动器依赖
xxxxxxxxxx
<!--redis做缓存操作,搜索RedisAutoConfiguration能找到redis相关配置对应的属性类,所有相关配置都在该属性类中-->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>
SpringBoot对redis的配置
host指定redis所在主机地址,
port指定redis在主机上的端口号,默认就是6379
如果指定了用户和密码还可以在配置文件中指定用户和密码,默认安装没有密码和用户名
xxxxxxxxxx
Spring
redis
host192.168.56.10
port6379
redis的自动配置类
redis的自动配置类给容器中添加了RedisTemplate<Object,Object>
对象【对应k-v键值对的数据】,用于操作redis对数据进行CRUD操作
一般操作k-v键值对都是字符串较多,因此自动配置类还专门给容器添加了一个StringRedisTemplate
对象,该类继承自RedisTemplate<String,String>
,对应的key
和value
是用String
的序列化来做的
🔎:StringRedisTemplate
对象和RedisTemplate<Object,Object>
对象的区别是序列化器不一样,RedisTemplate<Object,Object>
对象默认使用的是JDK的序列化器defaultSerializer=new JdkSerializationRedisSerializer
,意为着如果我们使用RedisTemplate<Object,Object>
对象来操作Redis,写入redis中的数据都是二进制的,没有可读性,即便服务器写入字符串,存入redis中的数据也会变成二进制的数据,在redis客户端上根本无法浏览;如果要使用RedisTemplate<Object,Object>
对象需要设置对应的序列化器为String或者json的序列化器;
🔎:StringRedisTemplate
对象就是使用的String的序列化器,RedisSerializer.string()
就是给StringRedisTemplate
对象设置Sting序列化器的方法,使用StringRedisTemplate
对象存放的时候数据是怎么样的,读取出来就是怎么样的,使用该对象操作Redis可读性非常高,操作字符串k-v键值对直接使用StringRedisTemplate
对象即可
【StringRedisTemplate
的源码】
注意这个RedisSerializer.string()
xxxxxxxxxx
public class StringRedisTemplate extends RedisTemplate<String, String> {
public StringRedisTemplate() {
setKeySerializer(RedisSerializer.string());
setValueSerializer(RedisSerializer.string());
setHashKeySerializer(RedisSerializer.string());
setHashValueSerializer(RedisSerializer.string());
}
public StringRedisTemplate(RedisConnectionFactory connectionFactory) {
this();
setConnectionFactory(connectionFactory);
afterPropertiesSet();
}
protected RedisConnection preProcessConnection(RedisConnection connection, boolean existingConnection) {
return new DefaultStringRedisConnection(connection);
}
}
RedisTemplate的使用
代码示例
xxxxxxxxxx
SpringRunner.class) (
public class MallProductApplicationTests {
StringRedisTemplate stringRedisTemplate;
public void testStringRedisTemplate(){
//RedisTemplate下有很多opsXXX,这主要牵扯到redis中不同的数据类型,本项目基本使用以下五种类型,更多后面复习redis再说
//1. stringRedisTemplate.opsForValue() 这是存放简单类型
//拿到ops操作对象
ValueOperations<String, String> ops = stringRedisTemplate.opsForValue();
//保存数据
ops.set("Hello","world_"+ UUID.randomUUID());
//从redis中查询对应key的数据
String hello = ops.get("Hello");
System.out.println("Hello:"+hello);//Hello:world_46a44c4e-8436-4eb0-9dc6-86f49e6aa2e2
//stringRedisTemplate.opsForHash() 这是value类型也是一个Map类型
//stringRedisTemplate.opsForList() 这是value类型是一个数组
//stringRedisTemplate.opsForSet() 这是value类型是一个Set集合
//stringRedisTemplate.opsForZSet() 这是value类型是一个ZSet带排序集合类型
}
}
redis中的存入的数据
jol-core用来查看java对象的对象头信息,这是openjdk提供的
引入依赖
xxxxxxxxxx
<!-- https://mvnrepository.com/artifact/org.openjdk.jol/jol-core -->
<dependency>
<groupId>org.openjdk.jol</groupId>
<artifactId>jol-core</artifactId>
<version>0.17</version>
</dependency>
使用方法
这里老师修改了原来的jar包重新打包的,里面的api是他自定义的,B站没有相关教程,后续看文档补充,后续更改使用jol-core自身的api以后成功运行
xxxxxxxxxx
package cn.itcast.n4;
import lombok.extern.slf4j.Slf4j;
import org.openjdk.jol.info.ClassLayout;
import java.io.IOException;
import java.util.Vector;
import java.util.concurrent.locks.LockSupport;
// -XX:-UseCompressedOops -XX:-UseCompressedClassPointers -XX:BiasedLockingStartupDelay=0 -XX:+PrintFlagsFinal
//-XX:-UseBiasedLocking tid=0x000000001f173000 -XX:BiasedLockingStartupDelay=0 -XX:+TraceBiasedLocking
topic = "c.TestBiased") (
public class TestBiased {
/*
[t1] - 29 00000000 00000000 00000000 00000000 00011111 01000101 01101000 00000101
[t2] - 29 00000000 00000000 00000000 00000000 00011111 01000101 11000001 00000101
*/
public static void main(String[] args) throws IOException, InterruptedException {
test1();
}
private static void test5() throws InterruptedException {
log.debug("begin");
for (int i = 0; i < 6; i++) {
Dog d = new Dog();
log.debug(ClassLayout.parseInstance(d).toPrintable());
Thread.sleep(1000);
}
}
static Thread t1, t2, t3;
private static void test4() throws InterruptedException {
Vector<Dog> list = new Vector<>();
int loopNumber = 39;
t1 = new Thread(() -> {
for (int i = 0; i < loopNumber; i++) {
Dog d = new Dog();
list.add(d);
synchronized (d) {
log.debug(i + "\t" + ClassLayout.parseInstance(d).toPrintable());
}
}
LockSupport.unpark(t2);
}, "t1");
t1.start();
t2 = new Thread(() -> {
LockSupport.park();
log.debug("===============> ");
for (int i = 0; i < loopNumber; i++) {
Dog d = list.get(i);
log.debug(i + "\t" + ClassLayout.parseInstance(d).toPrintable());
synchronized (d) {
log.debug(i + "\t" + ClassLayout.parseInstance(d).toPrintable());
}
log.debug(i + "\t" + ClassLayout.parseInstance(d).toPrintable());
}
LockSupport.unpark(t3);
}, "t2");
t2.start();
t3 = new Thread(() -> {
LockSupport.park();
log.debug("===============> ");
for (int i = 0; i < loopNumber; i++) {
Dog d = list.get(i);
log.debug(i + "\t" + ClassLayout.parseInstance(d).toPrintable());
synchronized (d) {
log.debug(i + "\t" + ClassLayout.parseInstance(d).toPrintable());
}
log.debug(i + "\t" + ClassLayout.parseInstance(d).toPrintable());
}
}, "t3");
t3.start();
t3.join();
log.debug(ClassLayout.parseInstance(new Dog()).toPrintable());
}
private static void test3() throws InterruptedException {
Vector<Dog> list = new Vector<>();
Thread t1 = new Thread(() -> {
for (int i = 0; i < 30; i++) {
Dog d = new Dog();
list.add(d);
synchronized (d) {
log.debug(i + "\t" + ClassLayout.parseInstance(d).toPrintable());
}
}
synchronized (list) {
list.notify();
}
}, "t1");
t1.start();
Thread t2 = new Thread(() -> {
synchronized (list) {
try {
list.wait();
} catch (InterruptedException e) {
e.printStackTrace();
}
}
log.debug("===============> ");
for (int i = 0; i < 30; i++) {
Dog d = list.get(i);
log.debug(i + "\t" + ClassLayout.parseInstance(d).toPrintable());
synchronized (d) {
log.debug(i + "\t" + ClassLayout.parseInstance(d).toPrintable());
}
log.debug(i + "\t" + ClassLayout.parseInstance(d).toPrintable());
}
}, "t2");
t2.start();
}
// 测试撤销偏向锁
private static void test2() throws InterruptedException {
Dog d = new Dog();
Thread t1 = new Thread(() -> {
synchronized (d) {
log.debug(ClassLayout.parseInstance(d).toPrintable());
}
synchronized (TestBiased.class) {
TestBiased.class.notify();
}
}, "t1");
t1.start();
Thread t2 = new Thread(() -> {
synchronized (TestBiased.class) {
try {
TestBiased.class.wait();
} catch (InterruptedException e) {
e.printStackTrace();
}
}
log.debug(ClassLayout.parseInstance(d).toPrintable());
synchronized (d) {
log.debug(ClassLayout.parseInstance(d).toPrintable());
}
log.debug(ClassLayout.parseInstance(d).toPrintable());
}, "t2");
t2.start();
}
// 测试偏向锁
private static void test1() {
Dog d = new Dog();
log.debug(ClassLayout.parseInstance(d).toPrintable());
try {
Thread.sleep(4000);
} catch (InterruptedException e) {
e.printStackTrace();
}
log.debug(ClassLayout.parseInstance(new Dog()).toPrintable());
}
}
class Dog {
}
工具包,包括字符串处理工具类StringUtils
等功能
引入依赖
xxxxxxxxxx
<dependency>
<groupId>org.apache.commons</groupId>
<artifactId>commons-lang3</artifactId>
</dependency>
boolean--->StringUtils.equals(CharSequence cs1, CharSequence cs2)
功能解析:比较两个字符序列cs1
、cs2
是否相等,如果相等该方法返回true,如果不相等返回false;该方法区分字符串的大小写,如StringUtils.equals("abc","ABC") = false
使用示例:queryWrapper.eq(dadAge,momAge);
示例含义:检查字符串变量dadAge
和字符串变量momAge
的字符内容和次序是否相等
补充说明:
🔎:String
类型实现了CharSequence
接口
🔎:该StringUtils.equals(CharSequence cs1, CharSequence cs2)
方法的参数列表的任意参数传参null
都不会抛异常,如StringUtils.equals(null, null) = true
,StringUtils.equals(null, "abc") = false
,StringUtils.equals("abc",null) = false
boolean ---> StringUtils.startsWith(final CharSequence str, final CharSequence prefix)
功能解析:判断字符串str
是否以字符串prefix
作为前缀,如果是则返回true
,否则返回false
使用示例:StringUtils.startWith(str,"lock-")
示例含义:判断字符串str
是否以lock-
作为前缀,是返回true
,否则返回false
String ---> StringUtils.substringAfterLast(final String str, final String separator)
功能解析:截取字符串str
中最后一个separator
字符或字符串后面的内容,不包含separator
字符或字符串
使用示例:StringUtils.substringAfterLast(str,"/")
示例含义:截取字符串str
最后一个/
后面的内容
String ---> DigestUtils.md5Hex(String data)
功能解析:将文本数据转换成16进制格式的MD5值
使用示例:String s=DigestUtils.md5Hex("123456")
示例含义:将字符串转换为对应的16进制MD5值
补充说明:
这是基本的MD5加密算法,不是加盐版本,计算出的密文大部分都可以通过彩虹表还原出原文
String ---> DigestUtils.md5Crypt(Bytes bytes)
功能解析:在文本数据data前加上盐$1$+8位随机字符
然后一起计算MD5值
使用示例:String s=DigestUtils.md5Crypt("123456".getBytes())
示例含义:将字符串数据前加上随机盐值后再整体计算MD5值
补充说明:
这里的盐值是随机的,格式为$1$+8位随机字符
,添加位置是我猜的,后面验证一下
String ---> DigestUtils.md5Crypt(Bytes bytes,String salt)
功能解析:在文本数据data前加上盐$1$+8位随机字符
然后一起计算MD5值
使用示例:String s=DigestUtils.md5Crypt("123456".getBytes(),"$1$qqqqqqqq")
示例含义:将字符串数据123456
前加上盐值$1$qqqqqqqq
后再整体计算MD5值
补充说明:
我们可以使用随机盐值对用户密码进行加密,同时保存加密使用的盐值,验证的时候再取出盐值进行验证
Zookeeper官方提供的Java客户端,用于在Java应用程序中实现对Zookeeper服务器的操作
使用官方Zookeeper客户端
引入依赖
在zookeeper的客户端中已经引入了slf4j-log4j12
,如果已经在其他地方也引入就会有Slf4j
日志标红提示,老师的解决办法是从zookeeper的依赖中移除slf4j-log4j12
我认为这里老师讲的是错误的,因为maven会自动处理重复的依赖项,除非两个相同依赖的版本不一致,另一方面从依赖树形结构图中没有找到期望被移除的slf4j-log4j12
,从报错信息上来看是logback-classic1.2.11
中的org.slf4j.impl.StaticLoggerBinder.class
和slf4j-reload4j.1.7.36
中的org.slf4j.impl.StaticLoggerBinder.class
两个类发生了冲突,通过网络搜索发现网上那个和slf4j-log4j12
冲突的报错信息确实是slf4j-log4j12-1.7.25.jar
,总之就是logback
和log4j
之间关于org/slf4j/impl/StaticLoggerBinder.class
这个类发生的冲突,不同jar包下的相同的全限定类名的类在不破坏JVM的双亲委派模型类加载机制情况下全限定类名相同的类只会加载被先加载的jar包中的对应类,jar包的加载顺序和classpath
参数有关,包路径越靠前越先被加载,加载顺序靠后的jar包中的全限定类名相同的类会被直接忽略掉不会再被加载,SpringBoot
的默认日志是logback
,log4j
是以前的主流日志,很多第三方工具包都使用的是log4j
,解决办法是排除logback
或者log4j
的其中一个,让整个项目使用其中的一种日志;【具体原因还需要深入分析】
【报错信息】
xxxxxxxxxx
SLF4J Class path contains multiple SLF4J bindings.
SLF4J Found binding in jar file /D /maven-repository/ch/qos/logback/logback-classic/1.2.11/logback-classic-1.2.11.jar!/org/slf4j/impl/StaticLoggerBinder.class
SLF4J Found binding in jar file /D /maven-repository/org/slf4j/slf4j-reload4j/1.7.36/slf4j-reload4j-1.7.36.jar!/org/slf4j/impl/StaticLoggerBinder.class
SLF4J See http //www.slf4j.org/codes.html#multiple_bindings for an explanation.
SLF4J Actual binding is of type ch.qos.logback.classic.util.ContextSelectorStaticBinder
【springboot排除logback依赖实例】
排除logback
就要把项目中所有的logback
都排除,只使用log4j
xxxxxxxxxx
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter</artifactId>
<exclusions>
<!-- 排除自带的logback依赖 -->
<exclusion>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-logging</artifactId>
</exclusion>
</exclusions>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
<exclusions>
<!-- 排除自带的logback依赖 -->
<exclusion>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-logging</artifactId>
</exclusion>
</exclusions>
</dependency>
【正常依赖】
xxxxxxxxxx
<dependency>
<groupId>org.apache.zookeeper</groupId>
<artifactId>zookeeper</artifactId>
<version>3.7.0</version>
</dependency>
【老师的排除slf4j-log4j12
示例】
🔎:这个方式是有效的
xxxxxxxxxx
<dependency>
<groupId>org.apache.zookeeper</groupId>
<artifactId>zookeeper</artifactId>
<version>3.7.0</version>
<exclusions>
<exclusion>
<groupId>org.slf4j</groupId>
<artifactId>slf4j-log4j12</artifactId>
</exclusion>
</exclusions>
</dependency>
【实测排除slf4j-reload4j
也行】
xxxxxxxxxx
<dependency>
<groupId>org.apache.zookeeper</groupId>
<artifactId>zookeeper</artifactId>
<version>3.7.0</version>
<exclusions>
<exclusion>
<groupId>org.slf4j</groupId>
<artifactId>slf4j-reload4j</artifactId>
</exclusion>
</exclusions>
</dependency>
zookeeper客户端对象的获取
Zookeeper
对象通过构造方法创建,构造方法只能是有参构造,必须传参连接字符串connectString
、连接超时时间sessionTimeOut
、监听器watcher
,该对象使用完以后必须调用close
方法来手动关闭连接
连接字符串connectString
:格式必须为"127.0.0.1:3000,127.0.0.1:3001,127.0.0.1:3002"
,参数值是Zookeeper服务器集群的地址
用逗号分隔各地址,注意逗号两边不能有空格
连接超时时间sessionTimeOut
:参数为int
类型,单位是毫秒
监听器Watcher
:Watcher
是一个接口,需要使用匿名内部类的方式重写process
方法来实例化对象,process
方法会在连接建立时和连接关闭时各执行一次
注意调用完Zookeeper的构造方法以后还在获取连接程序就会执行后续的代码,此时zookeeper对象只是赋值了对象地址因为建立连接较慢还没有完成初始化,其中的功能是无法正常使用,此时需要使用闭锁CountdownLatch
来实现对zookeeper初始化进行等待的效果
zookeeper对象的API简介
zookeeper.create()
方法能创建节点
zookeeper.exist()
方法能判断某个节点是否存在
zookeeper.getChildren()
方法能获取节点的子节点和数据内容
xxxxxxxxxx
public static void main(String[] args) {
ZooKeeper zooKeeper = null;
try {
//Zookeeper操作对象可以直接通过构造方法创建,构造方法只能是有参构造,必须传参连接字符串connectString、sessionTimeOut、watcher
//连接字符串connectString的格式必须为"127.0.0.1:3000,127.0.0.1:3001,127.0.0.1:3002",参数值是Zookeeper服务器集群的地址,用逗号分隔各地址,注意逗号两边不能有空格
//参数sessionTimeOut是int类型的连接超时时间,单位是毫秒
//监听器Watcher是一个接口,需要使用匿名内部类的方式来实例化对象
//使用完客户端对象以后调用close方法来关闭该客户端
zooKeeper = new ZooKeeper("192.168.200.132:2181", 30000, new Watcher() {
public void process(WatchedEvent event) {
System.out.println("此时才真正获取连接进行回调process方法,该方法会在获取Zookeeper连接和关闭Zookeeper连接的时候分别调用一次,因此一共会调用两次");
}
});
//Zookeeper对象的Api
//zookeeper.create方法能创建节点
//zookeeper.exist方法能判断某个节点是否存在
//zookeeper.getChildren方法能获取节点的子节点和数据内容
//注意,Zookeeper在获取连接的时候,调用获取Zookeeper对象的方法后续代码就已经在执行了
//这里经过测试zookeeper不是null,这里是老师讲错了,根据以往JUC里面学到的知识认为是赋值操作已经完成,但是还没有初始化好,实际该对象虽然不是null但是需要在建立好连接后才能使用
System.out.println("此时还在建立连接,Zookeeper仍然为null但是这里已经能够执行了");
System.out.println(zooKeeper==null);
/**执行效果
* 此时还在建立连接,Zookeeper仍然为null但是这里已经能够执行了
* false
* 此时才真正获取连接进行回调process方法,该方法会在获取Zookeeper连接和关闭Zookeeper连接的时候分别调用一次,因此一共会调用两次
* 此时才真正获取连接进行回调process方法,该方法会在获取Zookeeper连接和关闭Zookeeper连接的时候分别调用一次,因此一共会调用两次
* */
//Zookeeper客户端中引入了Slf4j
} catch (IOException e) {
e.printStackTrace();
}finally {
if (zooKeeper != null) {
try {
zooKeeper.close();
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
}
使用闭锁CountdownLatch
等待zookeeper对象初始化完成
注意,两次process方法回调时传参的WatchedEvent
对象的state
属性值是不同的,第一次获取连接是SyncConnected
,关闭连接时是Closed
,可以根据两个属性值来区分是获取连接还是关闭连接,该属性值的类型是枚举Event.keeperState
,可以通过该属性值和枚举值对比决定是否需要放行countDownLatch.await()
从而继续执行获取到zookeeper连接后的动作
❓:不是异步获取连接吗为什么这里属性值是同步连接
xxxxxxxxxx
/**执行效果
* 此时才真正获取连接进行回调process方法,该方法会在获取Zookeeper连接和关闭Zookeeper连接的时候分别调用一次,因此一共会调用两次
* 此时还在建立连接,Zookeeper已赋值对象地址但是对象还没完成初始化,此时这里已经能够执行了
* false
* WatchedEvent state:SyncConnected type:None path:null
* 此时才真正获取连接进行回调process方法,该方法会在获取Zookeeper连接和关闭Zookeeper连接的时候分别调用一次,因此一共会调用两次
* WatchedEvent state:Closed type:None path:null
* */
public static void main(String[] args) {
CountDownLatch countDownLatch = new CountDownLatch(1);
ZooKeeper zooKeeper = null;
try {
zooKeeper = new ZooKeeper("192.168.200.132:2181", 30000, new Watcher() {
public void process(WatchedEvent event) {
countDownLatch.countDown();
System.out.println("此时才真正获取连接进行回调process方法,该方法会在获取Zookeeper连接和关闭Zookeeper连接的时候分别调用一次,因此一共会调用两次");
System.out.println(event);
}
});
try {
countDownLatch.await();
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println("此时还在建立连接,Zookeeper以赋值对象地址但是对象还没完成初始化,此时这里已经能够执行了");
System.out.println(zooKeeper==null);
} catch (IOException e) {
e.printStackTrace();
}finally {
if (zooKeeper != null) {
try {
zooKeeper.close();
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
}
【优化后的通用模板代码】
xxxxxxxxxx
public static void main(String[] args) {
CountDownLatch countDownLatch = new CountDownLatch(1);
ZooKeeper zooKeeper = null;
try {
zooKeeper = new ZooKeeper("192.168.200.132:2181", 30000, new Watcher() {
public void process(WatchedEvent event) {
if(Event.KeeperState.SyncConnected.equals(event.getState())){
countDownLatch.countDown();
}
}
});
try {
countDownLatch.await();
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println("业务操作");
} catch (IOException e) {
e.printStackTrace();
}finally {
if (zooKeeper != null) {
try {
zooKeeper.close();
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
}
上述代码还不够完善,因为节点事件的监听回调依然会执行process()
方法,此时process
方法传参的event
和获取连接时回调process
方法的event
分别为WatchedEvent state:SyncConnected type:NodeChildrenChanged path:/earl
[1]和WatchedEvent state:SyncConnected type:None path:null
;注意这些event
中的state
属性为SyncConnected
,和关闭连接时的WatchedEvent state:Closed type:None path:null
该state
属性Closed
不同,由此区分关闭连接和其他事件;可以通过event
参数的type
属性来区分事件类型从而执行不同的回调逻辑,在state
属性为SyncConnected
的前提下,当type
为None
时表明回调由成功获取连接发起,当type
属性为事件类型时表明回调由事件发起;此外注意event
中的path
存储了事件监听节点的路径,通过该路径可以制定不同节点的事件回调逻辑;由此可以将节点事件回调分成获取连接、关闭连接、节点事件三个大类执行对应的回调逻辑,对节点事件可以通过事件节点路径来区分执行不同的回调逻辑,示例代码如下
在Zookeeper对象的构造方法传参watcher对象中通过event
对象的state
属性、type
属性和path
属性来分区获取连接回调、关闭连接回调和不同类型不同节点的节点事件回调
节点事件回调直接在Zookeeper构造方法传参中写一起不优雅,不方便读和改,判断逻辑复杂;业界常用的方式是在节点事件监听方法中传参Watcher
匿名实现重写process
方法来自定义节点事件的回调逻辑,连续回调需要对节点事件方法进行封装,通过在回调方法中递归调用该方法来实现连续回调
xxxxxxxxxx
public static void main(String[] args) {
CountDownLatch countDownLatch = new CountDownLatch(1);
ZooKeeper zooKeeper = null;
try {
zooKeeper = new ZooKeeper("192.168.200.132:2181", 30000, new Watcher() {
public void process(WatchedEvent event) {
Event.KeeperState eventState = event.getState();
if (Event.KeeperState.SyncConnected.equals(eventState) && Event.EventType.None.equals(event.getType())) {
//获取连接回调逻辑,通常让闭锁计数减1来放行主业务执行或者其他逻辑
countDownLatch.countDown();
} else if (Event.KeeperState.Closed.equals(eventState)) {
//关闭连接后的回调业务逻辑
} else {
//节点事件回调逻辑,可以根据事件类型和节点路径进一步区分具体节点不同事件类型的回调逻辑,这些事件回调都是一次性的,后续相同事件发生不会再回调,想要后续继续回调可以在回调事件中再调用对应的事件比如getChildren("/earl",true)来实现,但是这种在一个process方法中写完所有回调逻辑的方式不优雅;回调的业务逻辑一般不写在该process方法中,一般是在业务方法中通过重载方法getChildren("/earl",watcher)传参一个Watcher类型的匿名实现来替换布尔类型的watch变量,在匿名实现需要重写的process方法中去自定义回调逻辑,这种方式更方便代码的组织和修改,读起来也更容易,如下业务代码所示
}
}
});
countDownLatch.await();
System.out.println("业务操作");
//常用的事件监听和回调方式
List<String> children = zooKeeper.getChildren("/earl", new Watcher() {
public void process(WatchedEvent event) {
System.out.println("节点/earl的子节点发生变化触发的回调");
//如果需要多次回调,回调的方式一般是把该监听方法封装成一个单独的方法,在该方法的回调中递归调用方法本身,如果只是一次回调则只需要这种实现即可
}
});
//这个等待回调的方式有点野蛮,感觉JUC里面的保护性暂停用在这里很不错
System.in.read();
} catch (IOException e) {
e.printStackTrace();
} catch (InterruptedException e) {
e.printStackTrace();
} finally {
if (zooKeeper != null) {
try {
zooKeeper.close();
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
}
String ---> zookeeper.create(final String path,byte[] data,List<ACL> acl,CreateMode createMode) throws KeeperException, InterruptedException
功能解析:创建指定数据、指定数据下的节点,返回值为被创建节点的路径
使用示例:zooKeeper.create("/earl/testJavaClient", "Hello zookeeper".getBytes(), ZooDefs.Ids.OPEN_ACL_UNSAFE, CreateMode.PERSISTENT);
示例含义:创建一个路径为/earl/testJavaClient
,数据内容为Hello zookeeper
,允许所有客户端对该节点进行任何操作的永久节点
补充说明:
数据内容data
要求传参一个byte
数组,可以通过字符串.getBytes()
方法获取对应的byte数组
权限List<ACL>
有专门的枚举类ZooDefs.Ids
,常用的三种权限包括
ZooDefs.Ids.OPEN_ACL_UNSAFE
:所有客户端都可以对创建的节点做任何操作
ZooDefs.Ids.CREATOR_ALL_ACL
:创建节点的客户端可以对节点做任何操作
ZooDefs.Ids.READ_ACL_UNSAFE
:所有客户端都能对创建的节点做读取操作
节点类型createMode
也使用专门的枚举类CreateMode
,对应节点类型有以下四种
CreateMode.PERSISTENT
:创建的节点是永久节点
CreateMode.EPHEMERAL
:创建的节点是临时节点,这种方式创建的临时节点在调用zookeeper.close()
方法后节点会直接被zookeeper服务器秒删
CreateMode.EPHEMERAL_SEQUENTIAL
:创建的节点是序列化临时节点
CreateMode.PERSISTENT_SEQUENTIAL
:创建的节点是序列化永久节点
Stat ---> zookeeper.exists(String path, boolean watch) throws KeeperException, InterruptedException
功能解析:判断指定路径节点是否存在,如果返回值为null
说明对应节点不存在,如果返回值不为null
说明对应的节点存在;第一个参数是指定节点路径,第二个参数是指定是否要监听,指定true
表示要监听时间,指定false
表示不监听
使用示例:zooKeeper.exists("/earl/testJavaClient", false);
示例含义:查询路径为/earl/testJavaClient
的节点是否存在
补充说明:
exists
方法相当于zookeeper中的stat
指令,可以通过该方法的重载方法来做对节点删除和节点创建事件的监听
byte[] ---> zookeeper.getData(String path, boolean watch, Stat stat) throws KeeperException, InterruptedException
功能解析:查询已经存在的指定路径节点的内容数据,这里传参stat
是zooKeeper.exists("/earl/testJavaClient", false);
的返回值,暂时认为要查询指定节点的内容数据必须先查询该节点是否存在。第二个参数是指定是否要监听,指定true
表示要监听时间,指定false
表示不监听
使用示例:zooKeeper.getData("/earl/testJavaClient", false, exists);
示例含义:查询已经存在节点/earl/testJavaClient
的数据内容
补充说明:
getData
方法相当于zookeeper中的get
指令,可以通过该方法的重载方法来做对节点的数据变化监听
List<String> ---> zookeeper.getChildren(String path, boolean watch) throws KeeperException, InterruptedException
功能解析:查询一个指定节点下的全部子节点
使用示例:zooKeeper.getChildren("/earl", false);
示例含义:查询节点/earl
下的所有子节点
补充说明:
getChildren
方法相当于zookeeper中的ls
指令,可以通过该方法的重载方法来做对子节点的创建、删除、数据内容变化监听
Stat ---> zookeeper.setData(final String path, byte[] data, int version) throws KeeperException, InterruptedException
功能解析:更新一个指定节点的数据内容,第三个参数version
需要使用exists
方法查询获取Stat
返回值,用stat.getVersion()
来获取指定路径节点的版本号,如果更新时发现当前数据版本号和传参不一致,更新操作就会失败;即更新操作也需要事先查询指定节点是否存在且获取Stat
返回值作为更新方法传参
使用示例:zooKeeper.setData("/earl/testJavaClient", "hello earl".getBytes(), exists.getVersion())
示例含义:把节点/earl/testJavaClient
下的数据内容更新为hello earl
补充说明:
版本号也可以指定为-1,表示更新操作不关心版本号,本次更新操作一定会成功
void ---> zookeeper.delete(final String path, int version) throws InterruptedException, KeeperException
功能解析:删除指定路径节点,如果当前版本号和指定版本号不一致则删除失败
使用示例:zooKeeper.delete("earl/testJavaClient",exists.getVersion());
示例含义:删除节点/earl/testJavaClient
补充说明:
版本号也可以指定为-1,表示删除操作不关心版本号,本次删除操作一定会成功
如果要删除的节点不存在仍然执行了该方法会抛出异常,注意凡是涉及到事件监听的方法调用,调用事件监听方法的线程在事件发生前不能提前结束执行,事件发生时线程运行结束会导致无法执行事件监听成功后的回调,因此调用事件监听方法的线程不能在事件发生前结束,不能在等待期间发生异常,发生了异常如果没有捕获处理也会直接结束当前线程
Curator是Netflix贡献给Apache的,目前属于Apache的顶级项目,Curator针对Zookeeper提供了很多高级工具的封装,比如分布式锁、还解决了Zookeeper官方客户端诸如失败重连、多次反复事件监听很多缺陷
Curator主要解决了Zookeeper官方客户端的三类问题
封装ZooKeeper client
与ZooKeeper server
之间的连接处理
提供了一套Fluent风格的操作API
提供基于ZooKeeper
各种应用场景实现, 比如分布式锁服务、集群领导选举、共享计数器、缓存机制、分布式队列等抽象封装,这些实现都遵循Zookeeper
的最佳实践,并考虑了各种极端情况
Curator由核心框架curator-framework
和curator-recipes
两部分组成,curator-framework
主要对Zookeeper的底层做了许多封装,便于用户更方面操作Zookeeper;curator-recipes
对一些Zookeeper典型应用场景比如分布式锁做了封装
引入依赖
使用Curator需要分别引入curator-framework
和curator-recipes
,注意这两个依赖中都含有zookeeper
官方客户端依赖,使用zookeeper客户端需要与服务端的版本相同,实际上也没这么严格,我这里使用zookeeper3.5.7的服务器使用zookeeper3.7.0的客户端也没有出现任何问题,使用Curator并不需要使用zookeeper客户端,因此为了避免版本冲突问题,最好将zookeeper客户端从curator-framework
和curator-recipes
中排除出去,有需要的时候再单独进行引入
xxxxxxxxxx
<dependency>
<groupId>org.apache.curator</groupId>
<artifactId>curator-framework</artifactId>
<version>4.3.0</version>
<exclusions>
<exclusion>
<groupId>org.apache.zookeeper</groupId>
<artifactId>zookeeper</artifactId>
</exclusion>
</exclusions>
</dependency>
<dependency>
<groupId>org.apache.curator</groupId>
<artifactId>curator-recipes</artifactId>
<version>4.3.0</version>
<exclusions>
<exclusion>
<groupId>org.apache.zookeeper</groupId>
<artifactId>zookeeper</artifactId>
</exclusion>
</exclusions>
</dependency>
<dependency>
<groupId>org.apache.zookeeper</groupId>
<artifactId>zookeeper</artifactId>
<version>3.4.14</version>
</dependency>
编写配置类初始化curator-framework
的客户端CuratorFrameWork
,该客户端对象类似于Redis
客户端中的RedisTemplate
,Redisson
中的RedissonClient
配置类
CuratorFramework
是一个接口,该接口有一个子接口WatcherRemoveCuratorFramework
和一个子实现类CuratorFrameworkImpl
,子实现类CuratorFrameworkImpl
有两个子类NamespaceFacade
和WatcherRemovalFacade
,一般常用工厂类的newClient
方法来初始化CuratorFramework
组件,该方法有两个重载方法CuratorFramework. newClient()
Zookeeper官方客户端是不具备连接重试功能的,这就是Curator对Zookeeper官方客户端做出的优化之一
其中newClient(String connectString, RetryPolicy retryPolicy)
方法不需要指定会话和连接超时时间
第一个参数是指定Zookeeper服务器地址,参数格式为192.168.200.132:2181
,注释说这是一个服务器地址列表,虽然注释没指明具体格式,猜测使用逗号分隔多个服务器地址
第二个参数retryPolicy
是指定重试策略,RetryPolicy
是一个接口,有两个直接子类分别是SleepingRetry
和RetryForever
,前者表示有间歇的重试,后者表示持续重试,持续重试可能导致服务器浪费大量的资源,一般不推荐使用该策略;可以使用SleepingRetry
的子类RetryNTime
,该策略可以指定每隔多少时间重试一次,最多重试多少次;一般使用SleepingRetry
的子类ExponentialBackoffRetry
指数补偿重试,除了可以指定重试次数,还可以指定一个初始间隔时间,第一次在初始间隔时间重试,以后每次重试的间隔时间会递增,重试次数越多间隔时间越长,这样的策略设计更符合节省服务器资源的标准
第二个重载方法CuratorFramework newClient(String connectString, int sessionTimeoutMs, int connectionTimeoutMs, RetryPolicy retryPolicy)
需要额外指定会话和连接超时时间
初始化CuratorFramework
对象以后需要使用start方法手动启动一下,否则Curator底层很多方法或者功能都是不工作的,即使调用了也无法使用,Curator的大多功能都通过该对象进行调用
xxxxxxxxxx
/**
* @author Earl
* @version 1.0.0
* @描述 CuratorFramework的配置类
* @创建日期 2024/08/30
* @since 1.0.0
*/
public class CuratorConfig {
public CuratorFramework curatorFramework(){
//初始化一个重试策略,这里使用的是指数补偿策略
//初始重试间隔10s钟,最大重试次数3次,这种参数声明更喜欢使用多态的方式来进行声明
RetryPolicy retryPolicy = new ExponentialBackoffRetry(10000, 3);
CuratorFramework curatorFrameworkClient = CuratorFrameworkFactory.newClient("192.168.200.132", retryPolicy);
//手动启动curatorFrameworkClient
curatorFrameworkClient.start();
return curatorFrameworkClient;
}
}
相关内容参见后端--分布式锁
Redisson类似于Jedis,功能封装相较于Jedis更加丰富,Jedis只是一个性能很好的Redis客户端,功能太弱;Redisson中封装了很多类似分布式锁、Java常用分布式对象【BitSet, Set, Multimap, SortedSet, Map, List, Queue, BlockingQueue, Deque, BlockingDeque, Semaphore, Lock, AtomicLong, CountDownLatch, Publish / Subscribe, Bloom filter】,常用分布式服务的实现;
这些分布式对象或者集合和单机版的一些对象集合在设计上考虑的点不一样,都是分布式场景下的Set、Map、List集合、队列,双端队列、双端阻塞队列、阻塞队列、信号量、分布式锁、原子整数等等
此外还有一些基于Redis实现的分布式远程服务
Redisson提供了很多使用Redis最方便简单的方法,和Jedis的设计理念不同,jedis的目的就是在客户端使用Redis指令,Redisson的目的是让用户不要关注redis本身和对应的指令,只关注使用Redisson实现业务逻辑
Redisson是一个在Redis的基础上实现的Java驻内存数据网格,内存数据网格的意思就是给内存做格式化,格式化的意思是给存储介质画格子,可以向格子中写入数据;格式化达到清空数据的效果是一种表面现象,实际动作是画格子【解释的一坨】,早期的U盘购买以后不能直接用,需要下驱动格式化才能使用,这个步骤就是给U盘画格子;格式化看上去清空了数据,实际上是在重新画格子【猜测是移除旧的寻址数据,设置新的写入空间,写入数据的时候设置新的寻址规则】
Redisson的官方文档:https://github.com/redisson/redisson/wiki
引入依赖
pom.xml
🔎:我严重怀疑有场景启动器,这里的配置是使用原生redisson的配置【经过后期确认,Maven仓库确实有对应的场景启动器依赖org.redisson.redisson-spring-boot-starter
,可以在pringBoot的默认配置文件中对Redisson进行配置,配置示例后面遇到再补充】
xxxxxxxxxx
<dependency>
<groupId>org.redisson</groupId>
<artifactId>redisson</artifactId>
<version>3.17.1</version>
</dependency>
配置
Properties中没有提供对应的Redisson相关的配置项,不能在SpringBoot的默认配置文件中对Redisson进行配置,具体的配置方法可以参考文档的Configuration【配置方法】章节,可以通过代码、文件的方式对Redisson进行配置
Redisson可以通过用户提供的YAML格式的文本文件来进行配置,该YAML文件需要通过调用静态方法Config.fromYAML(new File("config-file.yaml"))
来创建Config配置类对象,通过配置类对象调用静态方法Redisson.create(Config config)
来实例化RedissonClient
对象,这个RedissonClient
对象就类似于操作Redis的StringRedisTemplate
,Redisson
通过该对象实现对Redis的所有操作,配置示例如下
xxxxxxxxxx
Config config = Config.fromYAML(new File("config-file.yaml"));
RedissonClient redisson = Redisson.create(config);
YAML的文件配置方式比较麻烦,程序化配置相对简单方便,程序化配置的方法是构造一个Config对象,调用对象的实例方法为该对象设置指定的参数,配置示例如下,推荐使用程序化配置方式
Redis地址必须以redis://
开头,后面跟redis服务器的ip和端口号;如果Redis启用了安全连接,则需要在开头使用rediss://
启动SSL连接标识启用安全连接
xxxxxxxxxx
Config config = new Config();
config.setTransportMode(TransportMode.EPOLL);
config.useClusterServers()
//可以用"rediss://"来启用SSL连接
.addNodeAddress("redis://127.0.0.1:7181");
配置Redis集群的模式
针对Redis以不同模式构建,RedissonClient的配置方式不同,但是也是大同小异,Redis常见的构建模式包括集群模式、云托管模式、单Redis节点模式、哨兵模式、主从模式、分片模式;配置对应模式的代码如下
【单机模式】
xxxxxxxxxx
Config config = new Config();
config.useSingleServer();
【分片模式】
参数addresses
是一个可变长度字符串类型参数,在分片模式下要传递多个Redis节点的地址
xxxxxxxxxx
Config config = new Config();
config.useClusterServers().addNodeAddress(String... addresses);
【自定义模式】
xxxxxxxxxx
Config config = new Config();
config.useCustomServers();
【主从模式】
xxxxxxxxxx
Config config = new Config();
config.useMasterSlaveServers();
【副本模式】
xxxxxxxxxx
Config config = new Config();
config.useReplicatedServers();
【哨兵模式】
xxxxxxxxxx
Config config = new Config();
config.useSentinelServers();
单Redis节点模式下的程序化配置方式
如果redis在本机,Redisson可以直接使用create方法以默认连接地址127.0.0.1:6379
初始化RedissonClient,示例如下
xxxxxxxxxx
// 默认连接地址 127.0.0.1:6379
RedissonClient redisson = Redisson.create();
如果redis不在本机,配置redis的模式、配置redis服务器地址和端口并用Redisson.create(config)
方法初始化RedissonClient
,示例如下
xxxxxxxxxx
Config config = new Config();
//设置配置对应的Redis模式并设置对应的Redis地址
config.useSingleServer().setAddress("myredisserver:6379");
RedissonClient redisson = Redisson.create(config);
常用配置示例
xxxxxxxxxx
public class RedissonConfig{
public RedissonClient redissonClient(){
Config config = new Config();//初始化一个Redisson配置对象
config.useSingleServer()//使用单机模式
.setAddress("redis://192.168.200.132:6173")//指定Redis服务器地址
.setDatabase(0)//Redis默认有16个数据库,Redisson可以通过`setDatabase(int database)`方法进行指定,默认传参0表示使用第一个数据库
.setUsername(String username)
.setPassword()//设置用户名和密码,当redis设置了用户名和密码,对应的Config也需要配置
.setConnectionMinimumIdleSize(10)//设置连接池最小空闲连接数,生产环境最好设置,开发环境无需设置
.setConnectionPoolSize(50)//设置连接池最大线程数[这里怀疑是连接数不是线程数,因为连接池不一定需要使用线程池]
.setIdleConnectionTimeout(60000)//设置连接池线程的最大空闲时间,单位是毫秒,连接池线程空闲超过该时间就会被销毁直到线程数小于连接池最小空闲数
.setConnectionTimeout()//设置客户端程序获取redis连接的超时时间,如果超过该时间客户端还没有获取到redis连接就会快速失败
.setTimeout();//设置响应超时时间,如果超过指定时间还没有响应就快速失败
RedissonClient redisson = Redisson.create(config);
return redisson;
}
}
最小配置示例
这只是最简单的配置,定制化的配置还有很多,需要翻阅文档,在该配置下可以使用Redisson提供的:
🔎:同时也意味着其他的配置都是可选配置,但是老师说生产环境上面的常用配置项都需要进行配置
基于Redis的分布式锁RLock
Redisson
是接口RedissonClient
的实现类,Redisson的构造方法使用protected修饰,不能在包以外的地方调用,用户无法使用该构造方法;用户需要使用create方法来创建RedissonClient
对象
无参的create()
方法默认写死了本机的6379作为redis服务器地址,也是调用有参的create方法创建RedissonClient
对象
有参的create(Config config)
方法通过调用Redisson
的构造方法传参config
来创建RedissonClient
对象
xxxxxxxxxx
public class RedissonConfig{
destoryMethod="shutdown")//@Bean注解的destoryMethod属性能指定应用程序关闭前调用自定义shutdown方法来销户该容器组件 (
public RedissonClient redissonClient(){
Config config = new Config();
config.useSingleServer().setAddress("redis://192.168.200.132:6173");
RedissonClient redisson = Redisson.create(config);
return redisson;
}
}
相关内容参见后端--分布式锁
从Spring3.1开始定义了org.springframework.cache.Cache
和org.springframework.cache.CacheManager
两个接口来统一不同的缓存技术,Cache
接口是来操作增删改查数据的,CacheManager
接口是来管理各种各样的缓存的,支持使用JCache[JSR-107]注解通过注解的方式来简化开发,SpringCache
属于Spring
的部分,不属于SpringBoot
官方文档:https://docs.spring.io/spring-framework/docs/5.3.39/reference/html/integration.html#cache
步骤1
步骤2
步骤3
步骤4
引入依赖
引入缓存场景启动器spring-boot-starter-cache
xxxxxxxxxx
<!--引入缓存相关的场景启动器-->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-cache</artifactId>
</dependency>
想要使用Redis作为缓存需要引入redis的场景启动器spring-boot-starter-data-redis
,使用基于netty的lettuce-core
做网络通信,吞吐量极大,但是老版本的lettuce-core
存在以下问题
🔎:netty没有指定堆外内存,会默认使用-Xmx100m作为堆外内存,在并发处理过程中,获取数据的量特别大【比如一次就是69K】,数据在传输、转换过程中都需要占用内存,导致内存分配不足,出现堆外内存溢出问题;当-Xmx调大成1G的时候,发现不会瞬间发生堆外内存溢出,甚至还能测出吞吐量好一会儿以后才会发生该异常;即使将该值调整到很大的值,也只能延迟该堆外内存溢出的情况,但是该异常永远都会出现,根本原因是源码中netty在运行过程中会判断需要使用多少内存,计数一旦超过常量DIRECT_MEMORY_LIMIT
【直接内存限制】就会抛OutOfDirectMemoryError
异常,调用操作完以后应该还要调用释放内存的方法并计数释放的内存使用量,但是在操作的过程中没有及时地调用减去已释放内存导致报错堆外内存溢出,直接内存限制使用的是虚拟机运行参数设置-Dio.netty.maxDirectMemory
xxxxxxxxxx
<!--redis做缓存操作,搜索RedisAutoConfiguration能找到redis相关配置对应的属性类,所有相关配置都在该属性类中-->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis</artifactId>
<exclusions>
<exclusion>
<groupId>io.lettuce</groupId>
<artifactId>lettuce-core</artifactId>
</exclusion>
</exclusions>
</dependency>
<!-- https://mvnrepository.com/artifact/io.lettuce/lettuce-core -->
<dependency>
<groupId>io.lettuce</groupId>
<artifactId>lettuce-core</artifactId>
<version>5.2.0.RELEASE</version>
</dependency>
配置示例
指定缓存的类型为redis即可
xxxxxxxxxx
#指定缓存的类型
spring.cache.type=redis
#指定缓存的名字,在CacheProperties中的cacheNames属性上有注释说明,缓存名字可以以逗号分隔的list集合来表示,缓存管理器会根据
#这里配置的名字来自动创建对应名字的缓存组件,但是在配置文件指定了缓存名字会禁用掉根据代码中自定义的缓存名字自动创建缓存组件的功能,
#我们希望一边使用一边生成缓存组件,所以不对该项进行配置
#spring.cache.cache-names=
#这种方式会指定所有缓存在Redis中的有效时间,不太好,一个是缓存有效时间粒度太大,一个是容易造成缓存雪崩
spring.cache.redis.time-to-live=3600000
#spring.cache.redis.key-prefix=CACHE_
spring.cache.redis.use-key-prefix=true
开启缓存功能
在启动类上添加注解@EnableCaching
来开启缓存功能
xxxxxxxxxx
"com.earl.mall.product.feign") (
"com/earl/mall/product/dao") (
public class MallProductApplication {
public static void main(String[] args) {
SpringApplication.run(MallProductApplication.class, args);
}
}
CacheAutoConfiguration
CacheAutoConfiguration
在RedisAutoConfiguration
后面才会配置,CacheAutoConfiguration
根据默认配置文件配置的缓存类型redis
来选择RedisCacheConfiguration
对缓存进行配置,RedisCacheConfiguration
会给容器注入一个RedisCacheManager
,缓存管理器RedisCacheManager
会按照我们定义的缓存名字调用this.customizerInvoker.customize(builder.build())
来帮助用户初始化所有缓存,初始化缓存前会调用方法determineConfiguration(resourceLoader.getClassLoader())
决定缓存初始化使用的配置,如果redisCacheConfiguration
不为空即有Redis
的缓存配置就拿到Redis
的缓存配置redisCacheConfiguration
,如果没有就使用默认配置,默认的缓存配置都是从RedisProperties
中获取的,调用cacheDefaults
方法构建RedisCacheManagerBuilder
对象准备构造RedisCacheManager
组件并根据缓存名字调用initialCacheNames
方法来初始化缓存组件,自动配置类中的RedisCacheConfiguration
是容器组件,而且只有有参构造方法,初始化org.springframework.boot.autoconfigure.cache.RedisCacheConfiguration
时所有的参数都来自于容器组件,对象ObjectProvider<org.springframework.data.redis.cache.RedisCacheConfiguration>
从容器中获取我们自定义的org.springframework.data.redis.cache.RedisCacheConfiguration
,如果我们没有提供自定义的redisCacheConfiguration
组件这里就赋不上值,如果redisCacheConfiguration
为空值就会使用默认的redisProperties
中的配置,想改缓存的配置需要给容器中添加一个redisCacheConfiguration
组件,该配置就会应用到当前RedisCacheManager
管理的所有缓存组件[缓存分区]中,注意org.springframework.data.redis.cache.RedisCacheConfiguration
不是当前类,当前类是org.springframework.boot.autoconfigure.cache.RedisCacheConfiguration
,org.springframework.data.redis.cache.RedisCacheConfiguration
中的属性ttl
、cacheNullValues
、keyPrefix
、keySerializationPair
、valueSerializationPair
分别指定缓存的有效时间、是否缓存空数据、是否加前缀、key
的序列化方式和value
的序列化方式;如果不指定自定义的RedisCacheConfiguration
,会调用defaultCacheConfig()
设置默认配置,该方法上的注释指明了默认redis缓存配置,分别为没有指定过期时间、支持缓存空值、缓存的key
支持添加前缀、默认前缀为当前缓存的名字、key
的序列化器使用的是StringRedisSerializer
,value的序列化器使用的JdkSerializationRedisSerializer
,涉及到的时间日期格式转化使用的是DefaultFormattingConversionService
,如果不使用默认的redis
缓存配置,需要我们向容器中自定义一个org.springframework.data.redis.cache.RedisCacheConfiguration
容器组件
xxxxxxxxxx
spring-boot-starter-cache2.1.8.RELEASE
--------------------------------------------------------------------------------------------------
CacheManager.class) (
CacheAspectSupport.class) (
value = CacheManager.class, name = "cacheResolver") (
CacheProperties.class)//配置文件中能配置的缓存相关属性在类CacheProperties中封装 (
CouchbaseAutoConfiguration.class, HazelcastAutoConfiguration.class, ({
HibernateJpaAutoConfiguration.class, RedisAutoConfiguration.class })//在Redis开启自动配置以后才开启缓存的自动配置
CacheConfigurationImportSelector.class) (
public class CacheAutoConfiguration {
public CacheManagerCustomizers cacheManagerCustomizers(ObjectProvider<CacheManagerCustomizer<?>> customizers) {
return new CacheManagerCustomizers(customizers.orderedStream().collect(Collectors.toList()));
}
public CacheManagerValidator cacheAutoConfigurationValidator(CacheProperties cacheProperties,
ObjectProvider<CacheManager> cacheManager) {
return new CacheManagerValidator(cacheProperties, cacheManager);
}
(LocalContainerEntityManagerFactoryBean.class)
(AbstractEntityManagerFactoryBean.class)
protected static class CacheManagerJpaDependencyConfiguration extends EntityManagerFactoryDependsOnPostProcessor {
public CacheManagerJpaDependencyConfiguration() {
super("cacheManager");
}
}
/**
* Bean used to validate that a CacheManager exists and provide a more meaningful
* exception.
*/
static class CacheManagerValidator implements InitializingBean {
private final CacheProperties cacheProperties;
private final ObjectProvider<CacheManager> cacheManager;
CacheManagerValidator(CacheProperties cacheProperties, ObjectProvider<CacheManager> cacheManager) {
this.cacheProperties = cacheProperties;
this.cacheManager = cacheManager;
}
public void afterPropertiesSet() {
Assert.notNull(this.cacheManager.getIfAvailable(),
() -> "No cache manager could " + "be auto-configured, check your configuration (caching "
+ "type is '" + this.cacheProperties.getType() + "')");
}
}
/**
* {@link ImportSelector} to add {@link CacheType} configuration classes.
*/
//使用CacheConfigurationImportSelector这个选择器又导入了很多缓存相关配置
static class CacheConfigurationImportSelector implements ImportSelector {
public String[] selectImports(AnnotationMetadata importingClassMetadata) {
CacheType[] types = CacheType.values();
String[] imports = new String[types.length];
for (int i = 0; i < types.length; i++) {
imports[i] = CacheConfigurations.getConfigurationClass(types[i]);//1️⃣ 从CacheConfigurations缓存配置类中根据缓存类型得到每一种缓存的配置,Redis类型的缓存从MAPPINGS中根据CacheType.REDIS获取到RedisCacheConfiguration.class。用户在默认配置文件中指明了缓存类型为redis就会导入跟Redis相关的缓存配置类RedisCacheConfiguration.class
}
return imports;
}
}
}
final class CacheConfigurations {
//MAPPING在静态代码块中初始化,导入CacheType.REDIS对应导入的是RedisCacheConfiguration.class
private static final Map<CacheType, Class<?>> MAPPINGS;
static {
Map<CacheType, Class<?>> mappings = new EnumMap<>(CacheType.class);
mappings.put(CacheType.GENERIC, GenericCacheConfiguration.class);
mappings.put(CacheType.EHCACHE, EhCacheCacheConfiguration.class);
mappings.put(CacheType.HAZELCAST, HazelcastCacheConfiguration.class);
mappings.put(CacheType.INFINISPAN, InfinispanCacheConfiguration.class);
mappings.put(CacheType.JCACHE, JCacheCacheConfiguration.class);
mappings.put(CacheType.COUCHBASE, CouchbaseCacheConfiguration.class);
mappings.put(CacheType.REDIS, RedisCacheConfiguration.class);2️⃣ //根据CacheType.REDIS类型获取到Redis缓存的具体配置RedisCacheConfiguration.class
mappings.put(CacheType.CAFFEINE, CaffeineCacheConfiguration.class);
mappings.put(CacheType.SIMPLE, SimpleCacheConfiguration.class);
mappings.put(CacheType.NONE, NoOpCacheConfiguration.class);
MAPPINGS = Collections.unmodifiableMap(mappings);
}
private CacheConfigurations() {
}
1️⃣ cacheConfigurations.getConfigurationClass(types[i])
//根据缓存的类型从MAPPING属性中获取每一种缓存
public static String getConfigurationClass(CacheType cacheType) {
Class<?> configurationClass = MAPPINGS.get(cacheType);
Assert.state(configurationClass != null, () -> "Unknown cache type " + cacheType);
return configurationClass.getName();
}
public static CacheType getType(String configurationClassName) {
for (Map.Entry<CacheType, Class<?>> entry : MAPPINGS.entrySet()) {
if (entry.getValue().getName().equals(configurationClassName)) {
return entry.getKey();
}
}
throw new IllegalStateException("Unknown configuration class " + configurationClassName);
}
}
2️⃣ RedisCacheConfiguration中做的配置
RedisConnectionFactory.class) (
RedisAutoConfiguration.class) (
RedisConnectionFactory.class) (
CacheManager.class) (
CacheCondition.class) (
class RedisCacheConfiguration {
private final CacheProperties cacheProperties;
private final CacheManagerCustomizers customizerInvoker;
private final org.springframework.data.redis.cache.RedisCacheConfiguration redisCacheConfiguration;
RedisCacheConfiguration(CacheProperties cacheProperties, CacheManagerCustomizers customizerInvoker,
ObjectProvider<org.springframework.data.redis.cache.RedisCacheConfiguration> redisCacheConfiguration) {
this.cacheProperties = cacheProperties;
this.customizerInvoker = customizerInvoker;
this.redisCacheConfiguration = redisCacheConfiguration.getIfAvailable();
}//对象ObjectProvider<org.springframework.data.redis.cache.RedisCacheConfiguration>从容器中获取我们自定义的redisCacheConfiguration,如果我们没有提供自定义的redisCacheConfiguration组件这里就赋不上值,如果redisCacheConfiguration为空值就会使用默认的redisProperties中的配置,想改缓存的配置需要给容器中添加一个redisCacheConfiguration组件,该配置就会应用到当前RedisCacheManager管理的所有缓存组件[缓存分区]中,注意org.springframework.data.redis.cache.RedisCacheConfiguration不是当前类,当前类是org.springframework.boot.autoconfigure.cache.RedisCacheConfiguration,org.springframework.data.redis.cache.RedisCacheConfiguration中的属性ttl、cacheNullValues、keyPrefix、keySerializationPair、valueSerializationPair分别指定缓存的有效时间、是否缓存空数据、是否加前缀、key的序列化方式和value的序列化方式;如果不指定自定义的RedisCacheConfiguration,会调用defaultCacheConfig()设置默认配置,该方法上的注释指明了默认redis缓存配置,分别为没有指定过期时间、支持缓存空值、缓存的key支持添加前缀、默认前缀为当前缓存的名字、key的序列化器使用的是StringRedisSerializer,value的序列化器使用的JdkSerializationRedisSerializer,涉及到的时间日期格式转化使用的是DefaultFormattingConversionService,如果不使用默认的redis缓存配置,需要我们向容器中自定义一个容器组件
//给容器中放入了一个缓存管理器组件
public RedisCacheManager cacheManager(RedisConnectionFactory redisConnectionFactory,
ResourceLoader resourceLoader) {
RedisCacheManagerBuilder builder = RedisCacheManager.builder(redisConnectionFactory)
.cacheDefaults(determineConfiguration(resourceLoader.getClassLoader()));//初始化缓存前会调用方法`determineConfiguration(resourceLoader.getClassLoader())`决定缓存初始化使用的配置,拿到Redis的缓存配置redisCacheConfiguration,调用cacheDefaults方法构建RedisCacheManagerBuilder对象准备构造RedisCacheManager组件
List<String> cacheNames = this.cacheProperties.getCacheNames();
if (!cacheNames.isEmpty()) {
builder.initialCacheNames(new LinkedHashSet<>(cacheNames));2️⃣-1️⃣ //根据缓存名字调用initialCacheNames来初始化缓存组件
}
return this.customizerInvoker.customize(builder.build());
}
private org.springframework.data.redis.cache.RedisCacheConfiguration determineConfiguration(
ClassLoader classLoader) {
if (this.redisCacheConfiguration != null) {
return this.redisCacheConfiguration;//如果redisCacheConfiguration不为空即有Redis的缓存配置就拿到Redis的缓存配置redisCacheConfiguration,如果没有就使用默认配置
}
Redis redisProperties = this.cacheProperties.getRedis();
org.springframework.data.redis.cache.RedisCacheConfiguration config = org.springframework.data.redis.cache.RedisCacheConfiguration
.defaultCacheConfig();//如果不指定自定义的RedisCacheConfiguration,会调用defaultCacheConfig()设置默认配置
config = config.serializeValuesWith(
SerializationPair.fromSerializer(new JdkSerializationRedisSerializer(classLoader)));//序列化机制使用JDK默认的序列化
if (redisProperties.getTimeToLive() != null) {
config = config.entryTtl(redisProperties.getTimeToLive());//从redisProperties中得到ttl过期时间,说实话,讲的一坨,唉;redisProperties是从当前类的cacheProperties属性中获取的,
}
if (redisProperties.getKeyPrefix() != null) {//每一个缓存的key有没有前缀
config = config.prefixKeysWith(redisProperties.getKeyPrefix());
}
if (!redisProperties.isCacheNullValues()) {//是否缓存空数据
config = config.disableCachingNullValues();
}
if (!redisProperties.isUseKeyPrefix()) {//是否使用缓存的前缀
config = config.disableKeyPrefix();
}
return config;
}//通过配置我们自己定义的`RedisCacheConfiguration`,会在`org.springframework.boot.autoconfigure.cache.RedisCacheConfiguration`中的`determineConfiguration(ClassLoader classLoader)`方法中取消用`cacheProperties`中的配置修改`org.springframework.data.redis.cache.RedisCacheConfiguration`中的对应属性,因此我们所有配置在默认配置文件中的属性都会失效<font color=green>**[比如我们在默认配置文件配置的缓存有效时间]**</font>,因为在执行自动配置时发现我们自己配置了`RedisCacheConfiguration`,就会直接返回我们配置的组件,不会再继续执行`cacheProperties`的配置属性赋值操作了;因此使用自定义`RedisCacheConfiguration`组件还需要将配置文件中的所有`Redis`缓存相关配置都配置到该组件中,可以参考`org.springframework.boot.autoconfigure.cache.RedisCacheConfiguration`中的`determineConfiguration(ClassLoader classLoader)`方法中的配置方式,这样我们仍然能通过配置文件指定对应的属性,缓存相关的配置类只是标注了注解`@ConfigurationProperties(prefix="spring.cache")`该注解只是声明该配置类和默认配置文件中以`spring.cache`为前缀的配置进行属性绑定,但是并没有将该配置类放入容器中,需要在配置使用类上标注`@EnableConfigurationProperties(CacheProperties.class)`导入该配置类,使用了该注解就能在配置使用类中通过`@Autowired`注解将配置类进行注入,此外给容器注入组件的方法的所有传参都会自动使用容器组件,因此我们不需要再使用`@Autowired`注解进行注入,直接给方法传参对应类型的组件即可,这就是完全模仿`org.springframework.boot.autoconfigure.cache.RedisCacheConfiguration`中的`determineConfiguration(ClassLoader classLoader)`方法做的配置
}
2️⃣-1️⃣ redisCacheManager.initialCacheNames(new LinkedHashSet<>(cacheNames))
public RedisCacheManagerBuilder initialCacheNames(Set<String> cacheNames) {
Assert.notNull(cacheNames, "CacheNames must not be null!");
Map<String, RedisCacheConfiguration> cacheConfigMap = new LinkedHashMap<>(cacheNames.size());
cacheNames.forEach(it -> cacheConfigMap.put(it, defaultCacheConfiguration));//对缓存名字进行遍历,将每个缓存名和默认缓存配置对应起来存入LinkedHashMap<String, RedisCacheConfiguration>类型的cacheConfigMap
return withInitialCacheConfigurations(cacheConfigMap);2️⃣-1️⃣-1️⃣ //利用这个使用了默认配置的cacheConfigMap来初始化缓存
}
2️⃣-1️⃣-1️⃣ redisCacheManager.redisCacheManagerBuilder.withInitialCacheConfigurations(cacheConfigMap)
public RedisCacheManagerBuilder withInitialCacheConfigurations(
Map<String, RedisCacheConfiguration> cacheConfigurations) {
Assert.notNull(cacheConfigurations, "CacheConfigurations must not be null!");
cacheConfigurations.forEach((cacheName, configuration) -> Assert.notNull(configuration,
String.format("RedisCacheConfiguration for cache %s must not be null!", cacheName)));
this.initialCaches.putAll(cacheConfigurations);//将cacheConfigMap存入LinkedHashMap<String, RedisCacheConfiguration>中
return this;
}
org.springframework.data.redis.cache.RedisCacheConfiguration
xxxxxxxxxx
spring-boot-starter-data-redis2.1.10RELEASED
--------------------------------------------------------------------------------------------------
public class RedisCacheConfiguration {
//通过自定义组件指定以下属性的方式可以自定义配置基于Redis的缓存过期时间、是否缓存空值、是否加前缀、key和value分别采用哪种序列化方式,如果不指定就会在org.springframework.boot.autoconfigure.cache.RedisCacheConfiguration的determineConfiguration方法中使用这个类的defaultCacheConfig()方法
private final Duration ttl;
private final boolean cacheNullValues;
private final CacheKeyPrefix keyPrefix;
private final boolean usePrefix;
private final SerializationPair<String> keySerializationPair;
private final SerializationPair<Object> valueSerializationPair;
private final ConversionService conversionService;
("unchecked")
private RedisCacheConfiguration(Duration ttl, Boolean cacheNullValues, Boolean usePrefix, CacheKeyPrefix keyPrefix,
SerializationPair<String> keySerializationPair, SerializationPair<?> valueSerializationPair,
ConversionService conversionService) {
this.ttl = ttl;
this.cacheNullValues = cacheNullValues;
this.usePrefix = usePrefix;
this.keyPrefix = keyPrefix;
this.keySerializationPair = keySerializationPair;
this.valueSerializationPair = (SerializationPair<Object>) valueSerializationPair;
this.conversionService = conversionService;
}
/**
* Default {@link RedisCacheConfiguration} using the following:
* <dl>
* <dt>key expiration</dt>
* <dd>eternal</dd>
* <dt>cache null values</dt>
* <dd>yes</dd>
* <dt>prefix cache keys</dt>
* <dd>yes</dd>
* <dt>default prefix</dt>
* <dd>[the actual cache name]</dd>
* <dt>key serializer</dt>
* <dd>{@link org.springframework.data.redis.serializer.StringRedisSerializer}</dd>
* <dt>value serializer</dt>
* <dd>{@link org.springframework.data.redis.serializer.JdkSerializationRedisSerializer}</dd>
* <dt>conversion service</dt>
* <dd>{@link DefaultFormattingConversionService} with {@link #registerDefaultConverters(ConverterRegistry) default}
* cache key converters</dd>
* </dl>
*
* @return new {@link RedisCacheConfiguration}.
* 该方法上的注释指明了默认redis缓存配置,分别为没有指定过期时间、支持缓存空值、缓存的key支持添加前缀、默认前缀为当前缓存的名字、key的序列化器使用的是StringRedisSerializer,value的序列化器使用的JdkSerializationRedisSerializer,涉及到的时间日期格式转化使用的是DefaultFormattingConversionService
*/
public static RedisCacheConfiguration defaultCacheConfig() {
return defaultCacheConfig(null);
}//defaultCacheConfig()方法的返回值还是RedisCacheConfiguration,而且更改其中配置的entryTtl(Duration ttl)方法的返回值还是RedisCacheConfiguration,说明我们可以通过链式调用的方式来更改RedisCacheConfiguration组件的属性配置,但是通过entryTtl(Duration ttl)方法我们可以发现,RedisCacheConfiguration更改配置的方式是将我们的目标值替换对应属性的旧值并以对应属性的新值和调用修改方法的原对象的属性旧值作为参数再构造一个全新的对象进行返回,因此我们可以先通过静态defaultCacheConfig()创建出一个默认配置的RedisCacheConfiguration对象,在默认配置的基础上调用serializeKeysWith(RedisSerializationContext.SerializationPair.fromSerializer(new StringRedisSerializer()))更改key的序列化方式为Redis的序列化器StringRedisSerializer(),调用serializeValuesWith(RedisSerializationContext.SerializationPair.fromSerializer(new GenericJackson2JsonRedisSerializer()))来更改value的序列化方式使用Spring提供的基于Jackson的乐意将任意类型对象转换成json格式字符串的序列化器[这里key一般都是指定字符串,所以使用StringRedisSerializer将字符串转换成json格式即可,但是value一般都是各种各样的对象,因此需要使用Generic标识的json格式序列化器,该序列化器在转换过程中还会添加一个@Class属性指定json对象对应的全限定类名],org.springframework.boot.autoconfigure.cache.RedisCacheConfiguration的determineConfiguration方法中也是通过这种方式将默认配置修改为redisProperties中的对应属性配置
/**
* Create default {@link RedisCacheConfiguration} given {@link ClassLoader} using the following:
* <dl>
* <dt>key expiration</dt>
* <dd>eternal</dd>
* <dt>cache null values</dt>
* <dd>yes</dd>
* <dt>prefix cache keys</dt>
* <dd>yes</dd>
* <dt>default prefix</dt>
* <dd>[the actual cache name]</dd>
* <dt>key serializer</dt>
* <dd>{@link org.springframework.data.redis.serializer.StringRedisSerializer}</dd>
* <dt>value serializer</dt>
* <dd>{@link org.springframework.data.redis.serializer.JdkSerializationRedisSerializer}</dd>
* <dt>conversion service</dt>
* <dd>{@link DefaultFormattingConversionService} with {@link #registerDefaultConverters(ConverterRegistry) default}
* cache key converters</dd>
* </dl>
*
* @param classLoader the {@link ClassLoader} used for deserialization by the
* {@link org.springframework.data.redis.serializer.JdkSerializationRedisSerializer}.
* @return new {@link RedisCacheConfiguration}.
* @since 2.1
*/
public static RedisCacheConfiguration defaultCacheConfig( ClassLoader classLoader) {
DefaultFormattingConversionService conversionService = new DefaultFormattingConversionService();
registerDefaultConverters(conversionService);
return new RedisCacheConfiguration(Duration.ZERO, true, true, CacheKeyPrefix.simple(),
SerializationPair.fromSerializer(RedisSerializer.string()),
SerializationPair.fromSerializer(RedisSerializer.java(classLoader)), conversionService);
}
/**
* Set the ttl to apply for cache entries. Use {@link Duration#ZERO} to declare an eternal cache.
*
* @param ttl must not be {@literal null}.
* @return new {@link RedisCacheConfiguration}.
*/
public RedisCacheConfiguration entryTtl(Duration ttl) {
Assert.notNull(ttl, "TTL duration must not be null!");
return new RedisCacheConfiguration(ttl, cacheNullValues, usePrefix, keyPrefix, keySerializationPair,
valueSerializationPair, conversionService);
}
/**
* Use the given prefix instead of the default one.
*
* @param prefix must not be {@literal null}.
* @return new {@link RedisCacheConfiguration}.
*/
public RedisCacheConfiguration prefixKeysWith(String prefix) {
Assert.notNull(prefix, "Prefix must not be null!");
return computePrefixWith((cacheName) -> prefix);
}
/**
* Use the given {@link CacheKeyPrefix} to compute the prefix for the actual Redis {@literal key} on the
* {@literal cache name}.
*
* @param cacheKeyPrefix must not be {@literal null}.
* @return new {@link RedisCacheConfiguration}.
* @since 2.0.4
*/
public RedisCacheConfiguration computePrefixWith(CacheKeyPrefix cacheKeyPrefix) {
Assert.notNull(cacheKeyPrefix, "Function for computing prefix must not be null!");
return new RedisCacheConfiguration(ttl, cacheNullValues, true, cacheKeyPrefix, keySerializationPair,
valueSerializationPair, conversionService);
}
/**
* Disable caching {@literal null} values. <br />
* <strong>NOTE</strong> any {@link org.springframework.cache.Cache#put(Object, Object)} operation involving
* {@literal null} value will error. Nothing will be written to Redis, nothing will be removed. An already existing
* key will still be there afterwards with the very same value as before.
*
* @return new {@link RedisCacheConfiguration}.
*/
public RedisCacheConfiguration disableCachingNullValues() {
return new RedisCacheConfiguration(ttl, false, usePrefix, keyPrefix, keySerializationPair, valueSerializationPair,
conversionService);
}
/**
* Disable using cache key prefixes. <br />
* <strong>NOTE</strong>: {@link Cache#clear()} might result in unintended removal of {@literal key}s in Redis. Make
* sure to use a dedicated Redis instance when disabling prefixes.
*
* @return new {@link RedisCacheConfiguration}.
*/
public RedisCacheConfiguration disableKeyPrefix() {
return new RedisCacheConfiguration(ttl, cacheNullValues, false, keyPrefix, keySerializationPair,
valueSerializationPair, conversionService);
}
/**
* Define the {@link ConversionService} used for cache key to {@link String} conversion.
*
* @param conversionService must not be {@literal null}.
* @return new {@link RedisCacheConfiguration}.
*/
public RedisCacheConfiguration withConversionService(ConversionService conversionService) {
Assert.notNull(conversionService, "ConversionService must not be null!");
return new RedisCacheConfiguration(ttl, cacheNullValues, usePrefix, keyPrefix, keySerializationPair,
valueSerializationPair, conversionService);
}
/**
* Define the {@link SerializationPair} used for de-/serializing cache keys.
*
* @param keySerializationPair must not be {@literal null}.
* @return new {@link RedisCacheConfiguration}.
*/
public RedisCacheConfiguration serializeKeysWith(SerializationPair<String> keySerializationPair) {
Assert.notNull(keySerializationPair, "KeySerializationPair must not be null!");
return new RedisCacheConfiguration(ttl, cacheNullValues, usePrefix, keyPrefix, keySerializationPair,
valueSerializationPair, conversionService);
}
/**
* Define the {@link SerializationPair} used for de-/serializing cache values.
*
* @param valueSerializationPair must not be {@literal null}.
* @return new {@link RedisCacheConfiguration}.
*/
public RedisCacheConfiguration serializeValuesWith(SerializationPair<?> valueSerializationPair) {
Assert.notNull(valueSerializationPair, "ValueSerializationPair must not be null!");
return new RedisCacheConfiguration(ttl, cacheNullValues, usePrefix, keyPrefix, keySerializationPair,
valueSerializationPair, conversionService);
}
/**
* @return never {@literal null}.
* @deprecated since 2.0.4. Please use {@link #getKeyPrefixFor(String)}.
*/
public Optional<String> getKeyPrefix() {
return usePrefix() ? Optional.of(keyPrefix.compute("")) : Optional.empty();
}
/**
* Get the computed {@literal key} prefix for a given {@literal cacheName}.
*
* @return never {@literal null}.
* @since 2.0.4
*/
public String getKeyPrefixFor(String cacheName) {
Assert.notNull(cacheName, "Cache name must not be null!");
return keyPrefix.compute(cacheName);
}
/**
* @return {@literal true} if cache keys need to be prefixed with the {@link #getKeyPrefixFor(String)} if present or
* the default which resolves to {@link Cache#getName()}.
*/
public boolean usePrefix() {
return usePrefix;
}
/**
* @return {@literal true} if caching {@literal null} is allowed.
*/
public boolean getAllowCacheNullValues() {
return cacheNullValues;
}
/**
* @return never {@literal null}.
*/
public SerializationPair<String> getKeySerializationPair() {
return keySerializationPair;
}
/**
* @return never {@literal null}.
*/
public SerializationPair<Object> getValueSerializationPair() {
return valueSerializationPair;
}
/**
* @return The expiration time (ttl) for cache entries. Never {@literal null}.
*/
public Duration getTtl() {
return ttl;
}
/**
* @return The {@link ConversionService} used for cache key to {@link String} conversion. Never {@literal null}.
*/
public ConversionService getConversionService() {
return conversionService;
}
/**
* Registers default cache key converters. The following converters get registered:
* <ul>
* <li>{@link String} to {@link byte byte[]} using UTF-8 encoding.</li>
* <li>{@link SimpleKey} to {@link String}</li>
*
* @param registry must not be {@literal null}.
*/
public static void registerDefaultConverters(ConverterRegistry registry) {
Assert.notNull(registry, "ConverterRegistry must not be null!");
registry.addConverter(String.class, byte[].class, source -> source.getBytes(StandardCharsets.UTF_8));
registry.addConverter(SimpleKey.class, String.class, SimpleKey::toString);
}
}
自定义组件org.springframework.data.redis.cache.RedisCacheConfiguration
org.springframework.data.redis.cache.RedisCacheConfiguration
的defaultCacheConfig()
方法的返回值还是RedisCacheConfiguration
,而且更改其中配置的entryTtl(Duration ttl)
方法的返回值还是RedisCacheConfiguration
,说明我们可以通过链式调用的方式来更改RedisCacheConfiguration
组件的属性配置,但是通过entryTtl(Duration ttl)
方法我们可以发现,RedisCacheConfiguration
更改配置的方式是将我们的目标值替换对应属性的旧值并以对应属性的新值和调用修改方法的原对象的属性旧值作为参数再构造一个全新的对象进行返回,因此我们可以先通过静态defaultCacheConfig()
创建出一个默认配置的RedisCacheConfiguration
对象,在默认配置的基础上调用serializeKeysWith(RedisSerializationContext.SerializationPair.fromSerializer(new StringRedisSerializer()))
更改key的序列化方式为Redis的序列化器StringRedisSerializer()
,调用serializeValuesWith(RedisSerializationContext.SerializationPair.fromSerializer(new GenericJackson2JsonRedisSerializer()))
来更改value
的序列化方式使用Spring
提供的基于Jackson
的乐意将任意类型对象转换成json
格式字符串的序列化器[这里key一般都是指定字符串,所以使用StringRedisSerializer将字符串转换成json格式即可,但是value一般都是各种各样的对象,因此需要使用Generic标识的json格式序列化器,该序列化器在转换过程中还会添加一个@Class属性指定json对象对应的全限定类名],org.springframework.boot.autoconfigure.cache.RedisCacheConfiguration
的determineConfiguration
方法中也是通过这种方式将默认配置修改为redisProperties
中的对应属性配置
构造Redis
的序列化器需要通过方法RedisSerializationContext.SerializationPair.fromSerializer(new StringRedisSerializer())
传参对应的Redis
的序列化器RedisSerializer
,接口RedisSerializer
的直接实现类有十个,实现类中名字带Json的都是和json有关的序列化器,其中的GenericFastJsonRedisSerializer
这种带Generic
的实现类实现的是RedisSerializer<Object>
支持转换任意类型的对象,该对象使用的是fastjson
,如果系统中引入了fastjson
就可以使用该序列化器,如果系统中没有引入就使用Spring提供的org.springframework.data.redis.serializer.GenericJackson2JsonRedisSerializer
,这两个序列化器效果都是一样的
通过配置我们自己定义的RedisCacheConfiguration
,会在org.springframework.boot.autoconfigure.cache.RedisCacheConfiguration
中的determineConfiguration(ClassLoader classLoader)
方法中取消用cacheProperties
中的配置修改org.springframework.data.redis.cache.RedisCacheConfiguration
中的对应属性,因此我们所有配置在默认配置文件中的属性都会失效[比如我们在默认配置文件配置的缓存有效时间],因为在执行自动配置时发现我们自己配置了RedisCacheConfiguration
,就会直接返回我们配置的组件,不会再继续执行cacheProperties
的配置属性赋值操作了;因此使用自定义RedisCacheConfiguration
组件还需要将配置文件中的所有Redis
缓存相关配置都配置到该组件中,可以参考org.springframework.boot.autoconfigure.cache.RedisCacheConfiguration
中的determineConfiguration(ClassLoader classLoader)
方法中的配置方式,这样我们仍然能通过配置文件指定对应的属性,缓存相关的配置类只是标注了注解@ConfigurationProperties(prefix="spring.cache")
该注解只是声明该配置类和默认配置文件中以spring.cache
为前缀的配置进行属性绑定,但是并没有将该配置类放入容器中,需要在配置使用类上标注@EnableConfigurationProperties(CacheProperties.class)
导入该配置类,使用了该注解就能在配置使用类中通过@Autowired
注解将配置类进行注入,此外给容器注入组件的方法的所有传参都会自动使用容器组件,因此我们不需要再使用@Autowired
注解进行注入,直接给方法传参对应类型的组件即可,这就是完全模仿org.springframework.boot.autoconfigure.cache.RedisCacheConfiguration
中的determineConfiguration(ClassLoader classLoader)
方法做的配置,配置示例如下例代码所示
❓:在org.springframework.boot.autoconfigure.cache.RedisCacheConfiguration
中也使用了cacheProerties
,但是并没有使用下例所示的@EnableConfigurationProperties(CacheProperties.class)
注解,而是使用了@Conditional(CacheCondition.class)
注解,对应的配置类是通过该组件的构造方法传递进去的,思考以下@Conditional(CacheCondition.class)
注解的作用,并思考能否采用这种方式来获取配置类
使用其他缓存媒介也可以通过上述方法来自定义对应的缓存规则
xxxxxxxxxx
/**
* @author Earl
* @version 1.0.0
* @描述 自定义缓存配置,该配置会自动在使用SpringCache相关功能时自动生效
* @创建日期 2024/09/03
* @since 1.0.0
*/
CacheProperties.class) (
public class CustomCacheConfig {
RedisCacheConfiguration redisCacheConfiguration(CacheProperties cacheProperties){
RedisCacheConfiguration config = RedisCacheConfiguration.defaultCacheConfig();
config = config.serializeKeysWith(RedisSerializationContext.SerializationPair.fromSerializer(new StringRedisSerializer()))
.serializeValuesWith(RedisSerializationContext.SerializationPair.fromSerializer(new GenericJackson2JsonRedisSerializer()));
//下面完全是模仿`org.springframework.boot.autoconfigure.cache.RedisCacheConfiguration`的`determineConfiguration`方法
CacheProperties.Redis redisProperties = cacheProperties.getRedis();
if (redisProperties.getTimeToLive() != null) {
config = config.entryTtl(redisProperties.getTimeToLive());
}
if (redisProperties.getKeyPrefix() != null) {
config = config.prefixKeysWith(redisProperties.getKeyPrefix());
}
if (!redisProperties.isCacheNullValues()) {
config = config.disableCachingNullValues();
}
if (!redisProperties.isUseKeyPrefix()) {
config = config.disableKeyPrefix();
}
return config;
}
}
使用SpringCache
提供的以下几个注解@Cacheable
,@CacheEvict
,@CachePut
,@Caching
,@CacheConfig
就能完成日常开发中缓存的大部分功能
@Cacheable
:Triggers cache population.
触发将数据保存到缓存的操作,标注在方法上表示当前方法的结果需要缓存;而且如果方法的返回结果在缓存中有,方法都不需要调用;如果缓存中没有,就会调用被标注的方法获取缓存并将结果进行缓存
🔎:这种方式适用于读模式下添加缓存,存储同一种业务类型的数据,我们都将缓存指定为同一个分区,比如不管是一级商品分类数据还是全部商品分类数据,我们都划分为同一个缓存分区,这样就能方便地修改一个相关数据就能一下清除掉整个缓存分区
注意使用该注解重建缓存只需要加分布式锁
缓存数据建议按照业务类型来对缓存数据进行分区,该注解的value
属性和cacheNames
属性互为别名,属性的数据类型均为String[]
,表示可以给一个或者多个缓存组件同时放入一份被标注方法的返回值,在Redis中缓存的key为Cache自动生成的category::SimpleKey []
即缓存的名字::SimpleKey []
;其中的缓存数据因为使用的是JDK的序列化方式,Redis客户端直接读取出来全是二进制码,但是读取到java客户端以后被反序列化以后就可以变成正常的字符串信息,示例如下:
注意啊这种方式设置的缓存,默认是不设置有效时间的,即ttl=-1
,意味着缓存永远不会过期,这大部分情况下是不可接受的
key也是系统默认自己生成而不是用户指定的,我们更希望这个key能由我们自己进行指定
使用默认的JDK来序列化缓存数据,不符合互联网数据大多以json形式交互的规范,如果一个PHP架构的异构系统想要获取缓存数据如果是经过JDK序列化就可能导致和异构系统不兼容,因此我们更希望使用json格式的缓存数据
xxxxxxxxxx
"category","product"})//将该方法的返回值同时给category和product缓存组件中各放入一份 ({
public List<CategoryEntity> getAllFirstLevelCategory() {
List<CategoryEntity> firstLevelCategories = baseMapper.selectList(new QueryWrapper<CategoryEntity>().eq("cat_level", 1));
return firstLevelCategories;
}
存在问题分析
通过@Cacheable
注解的key
属性,该属性的注释上标注了key属性值接受一个Spring Expression Language
[SpEL表达式],意思是这个key
可以不写死,可以通过#root.methodName
获取当前的方法名作为key
、#root.args[1]
获取参数列表中的参数值来作为key
等等
缓存的过期时间无法通过该注解的属性指定,但是可以在SpringBoot默认配置文件中通过spring.cache.redis.time-to-live=3600000
指定以毫秒为单位的过期时间,这里是指定缓存的过期时间为一个小时,不过这种方式很容易导致缓存雪崩
除了以上介绍还可以通过属性keyGenerator
指定一个key的生成器、通过属性cacheManager
指定缓存管理器、通过属性condition()
还能指定添加缓存的条件[接受一个SpEL表达式]、通过属性unless
指定在除非满足指定条件下才将方法返回值添加缓存、通过属性sync
指定通过同步的方式添加缓存[使用同步的方式unless
属性就无法使用]
SpEL表达式:
Location是定位使用的根对象
Name | Location | Description | Example |
---|---|---|---|
methodName | Root object | 使用方法名作为SpEl表达式 | #root.methodName |
method | Root object | 使用方法名作为SpEl表达式 | #root.method.name |
target | Root object | The target object being invoked | #root.target |
targetClass | Root object | The class of the target being invoked | #root.targetClass |
args | Root object | 按顺序取出所有的参数,使用下标索引来获取指定的参数 | #root.args[0] |
caches | Root object | 当前方法配置的value属性的第一个缓存组件名字 | #root.caches[0].name |
Argument name | Evaluation context | Name of any of the method arguments. If the names are not available (perhaps due to having no debug information), the argument names are also available under the #a<#arg> where #arg stands for the argument index (starting from 0 ). | #iban or #a0 (you can also use #p0 or #p<#arg> notation as an alias). |
result | Evaluation context | The result of the method call (the value to be cached). Only available in unless expressions, cache put expressions (to compute the key ), or cache evict expressions (when beforeInvocation is false ). For supported wrappers (such as Optional ), #result refers to the actual object, not the wrapper. | #result |
表达式中使用字符串需要使用单引号括起来
除了SpEL表达式还可以通过以下方式来自定义key,如下例所示
xxxxxxxxxx
value="users", key="#id") (
public User find(Integer id) {
returnnull;
}
value="users", key="#p0") (
public User find(Integer id) {
returnnull;
}
value="users", key="#user.id") (
public User find(User user) {
returnnull;
}
value="users", key="#p0.id") (
public User find(User user) {
returnnull;
}
【指定key的代码示例】
xxxxxxxxxx
value = {"category"},key="#root.method.name") (
public List<CategoryEntity> getAllFirstLevelCategory() {
List<CategoryEntity> firstLevelCategories = baseMapper.selectList(new QueryWrapper<CategoryEntity>().eq("cat_level", 1));
return firstLevelCategories;
}
【指定过期时间的配置示例】
xxxxxxxxxx
#这种方式会指定所有缓存在Redis中的有效时间,不太好,一个是缓存有效时间粒度太大,一个是容易造成缓存雪崩
spring.cache.redis.time-to-live=3600000
【指定缓存的key前缀】
实际开发中更推荐使用缓存分区的名字作为缓存key的前缀,即不再指定spring.cache.redis.key-prefix=CACHE_
,使用默认的前缀配置,同时不要禁用使用key的前缀,这样做的好处是以缓存分区的名字作为前缀会在Redis中以缓存分区名字作为根目录,在根目录下跟完整key的缓存键值对,这样看起来分区逻辑更明了
【自定义key前缀的缓存数据结构】
【默认缓存分区名字作为key前缀的缓存数据结构】
xxxxxxxxxx
#在缓存的key前面加上一个前缀来作为某种标识,这里使用CACHE_前缀标识以CACHE_开头的键值对都是缓存,注意这个key指定了前缀是以我们`指定的前缀`+`_`+`指定的key`作为缓存的key,此时前缀会取代默认的缓存分区名字,缓存的key就不会再自动以缓存分区名字作为key的前缀了
spring.cache.redis.key-prefix=CACHE_
#该配置表示是否开启对key前缀配置的使用,默认值是true,如果不想使用前缀可以指定false来禁用掉,如果禁用掉前缀连默认的以缓存分区名字作为前缀都会被禁用掉,用户在@Cacheable注解中指定的key是什么样对应缓存的key就是一模一样的
spring.cache.redis.use-key-prefix=true
【指定是否缓存空值】
xxxxxxxxxx
#指定是否缓存空值,默认也是true,对于缓存穿透问题要求我们对不存在的结果进行空值缓存,这样能防止恶意请求对不存在的数据进行高频直接访问数据库来达到攻击数据库的目的,因此一般都要开启空值缓存,这样当使用SpringCache的相关注解功能时,如果查询到结果为null,也会将对应的空值缓存到缓存媒介中,缓存空值会使用一个NullValue对象来封装空值,缓存中能看到NullValue的全限定类名
spring.cache.redis.cache-null-values=true
将数据保存为json格式就比较麻烦了,牵涉到自定义缓存管理器
原理见下面的自动配置说明,并结合谷粒商城项目的P167-P170
来进行理解,这一块讲的很妙啊,应该多次回味,市面上很少有SpringCache
的相关教程
xxxxxxxxxx
/**
* @author Earl
* @version 1.0.0
* @描述 自定义缓存配置,该配置会自动在使用SpringCache相关功能时自动生效
* @创建日期 2024/09/03
* @since 1.0.0
*/
CacheProperties.class) (
public class CustomCacheConfig {
RedisCacheConfiguration redisCacheConfiguration(CacheProperties cacheProperties){
RedisCacheConfiguration config = RedisCacheConfiguration.defaultCacheConfig();
config = config.serializeKeysWith(RedisSerializationContext.SerializationPair.fromSerializer(new StringRedisSerializer()))
.serializeValuesWith(RedisSerializationContext.SerializationPair.fromSerializer(new GenericJackson2JsonRedisSerializer()));
//下面完全是模仿`org.springframework.boot.autoconfigure.cache.RedisCacheConfiguration`的`determineConfiguration`方法
CacheProperties.Redis redisProperties = cacheProperties.getRedis();
if (redisProperties.getTimeToLive() != null) {
config = config.entryTtl(redisProperties.getTimeToLive());
}
if (redisProperties.getKeyPrefix() != null) {
config = config.prefixKeysWith(redisProperties.getKeyPrefix());
}
if (!redisProperties.isCacheNullValues()) {
config = config.disableCachingNullValues();
}
if (!redisProperties.isUseKeyPrefix()) {
config = config.disableKeyPrefix();
}
return config;
}
}
@CacheEvict
:Triggers cache eviction.
触发将数据从缓存中删除的操作,只是在调用方法以后删除指定键值对的缓存,下次对相关数据进行读操作时会由对应的读数据方法重建缓存
🔎:这种方式适用于在写数据库的情况下使用失效模式在写数据库以后清空对应的缓存
通过注解@CacheEvict
的value
属性能指定要清空缓存所在的缓存分区,通过key属性指定要清空的目标缓存的key,value属性都是String
类型的参数值,key属性值需要填入SpEl表达式,如果key属性值不是SpEL表达式正常情况下项目启动控制台就会报错说取不出该属性值[不加单引号表示字符串的都会被认为是动态取值],但是编译不会报错
xxxxxxxxxx
value="category",key="'getAllFirstLevelCategory'") (
public void updateRelatedData(CategoryEntity category) {
this.updateById(category);
if(StringUtils.hasLength(category.getName())){
categoryBrandRelationDao.updateCategoryNameByCatId(category.getCatId(),category.getName());
}
//TODO 商品分类名称变化更新对应的冗余字段
}
在通过注解@CacheEvict
的value
属性指定缓存分区后,将布尔类型的属性allEntries
设置为true
,此时每次执行@CacheEvict
注解标注的方法都会直接将整个缓存分区的所有缓存数据删掉
通过这种方式能批量直接删除一个缓存分区中的所有数据
xxxxxxxxxx
value="category",allEntries = true) (
public void updateRelatedData(CategoryEntity category) {
this.updateById(category);
if(StringUtils.hasLength(category.getName())){
categoryBrandRelationDao.updateCategoryNameByCatId(category.getCatId(),category.getName());
}
//TODO 商品分类名称变化更新对应的冗余字段
}
@CachePut
:以不影响方法执行的方式更新缓存业务类型对缓存进行分区,即使有缓存数据也会去执行业务方法,并且在业务方法执行结束以后将方法的返回结果替换掉缓存中相同key的缓存数据
@Caching
:组合@Cacheable
,@CacheEvict
,@CachePut
多个缓存操作来一次执行
@Caching
注解中的属性类型分别是Cacheable[]
、CachePut[]
、CacheEvict[]
,即可以通过该注解指定多个@Cacheable
,@CacheEvict
,@CachePut
操作,可以进行多缓存分区多种缓存操作类型
xxxxxxxxxx
evict = { (
value = "category",key = "'getAllFirstLevelCategory'"), (
value = "category",key = "'getIndexCategories'") (
})
public void updateRelatedData(CategoryEntity category) {
this.updateById(category);
if(StringUtils.hasLength(category.getName())){
categoryBrandRelationDao.updateCategoryNameByCatId(category.getCatId(),category.getName());
}
//TODO 商品分类名称变化更新对应的冗余字段
}
@CacheConfig
:在类级别即一个类上共享缓存的相同配置
问题描述
使用@Cacheable注解时,一个方法A调同一个类里的另一个有缓存注解的方法B,这样是不走缓存的。例如在同一个service里面两个方法的调用,被调用方法加了缓存是不生效的
代码示例
xxxxxxxxxx
// get 方法调用了 stockGive 方法,stockGive 方法使用了缓存
// 但是每次执行get 方法的时候,缓存都没有生成,也就是缓存没有被创建
public void get(){
stockGive(0L);
}
value = CacheConfig.COMMON, key = "'stock/give'+#memberId") (
public List<Map<String, Object>> stockGive(Long memberId) {
// do something
}
解决办法
1️⃣:不使用注解的方式,直接取 Ehcache 的 CacheManger 对象,把需要缓存的数据放到里面,类似于使用 Map,缓存的逻辑自己控制;或者可以使用redis的缓存方式去添加缓存;
2️⃣:把方法A和方法B放到两个不同的类里面,例如:如果两个方法都在同一个service接口里,把方法B放到另一个service里面,这样在A方法里调B方法,就可以使用B方法的缓存
缓存没有被正常创建的原因
因为@Cacheable
是使用AOP代理实现的 ,通过创建内部类来代理缓存方法,这样就会导致一个问题,类内部的方法调用类内部的缓存方法不会走代理,不会走代理,就不能正常创建缓存,所以每次都需要去调用数据库。
使用@Cacheable
注解的一些注意点
因为@Cacheable 由AOP 实现,所以,如果该方法被其它注解切入,当缓存命中的时候,则其它注解不能正常切入并执行,@Before 也不行,当缓存没有命中的时候,其它注解可以正常工作
标注了@Cacheable注解的方法不能进行内部方法调用,否则缓存无法创建
@Cacheable
标注的方法,如果其所在的类实现了某一个接口,那么该方法也必须出现在接口里面,否则cache无效。
具体的原因是, Spring把实现类装载成为Bean的时候,会用代理包装一下,所以从Spring Bean的角度看,只有接口里面的方法是可见的,其它的都隐藏了,自然课看不到实现类里面的非接口方法,@Cacheable不起作用。解决办法是把对应的方法定义在接口中,接口中的对应方法不需要标注@Cacheable
注解,@Cacheable注解也不能放接口里面
如果某一个Bean并没有实现任何接口,@Cacheable标注的方法只需要满足权限修饰符为public即可,因为这种Bean被Spring产生了代理, 看得到的只有public方法,本质上是Spring代理的问题,很多的基础设施比如安全,事务,日志等等可能都会遇到类似的问题
使用说明图
一个应用要使用SpringCache
要首先给当前应用配置一个或者多个缓存管理器CacheManager
org.springframework.cache.CacheManager
缓存管理器只有两个功能,第一个功能是按照String类型的名字获取缓存,第二个功能是获取当前缓存管理器管理的所有缓存的名字集合
CacheManager
的实现非常多,直接实现类就有7个,比如ConcurrentMapCacheManager
即该缓存管理器管理的所有缓存都是使用ConcurrentMap
来做的,Redis对应也有缓存管理器RedisCacheManager
,只要有对应的缓存管理器和缓存组件实现类,SpringCache就能兼容无限多种缓存场景
老师说就把缓存管理器比作市政府,用来定制管理缓存组件即各个区的方法,比如缓存数据的过期时间是多少、缓存组件的缓存数据如何和具体的缓存媒介数据相互转换的,用缓存组件来保存缓存数据,每个缓存组件就相当于一个区,里面可以组织存放相关业务逻辑的缓存数据,只要清空一个缓存组件的缓存数据就能直接清空对应缓存媒介中关联的全部缓存
xxxxxxxxxx
public interface CacheManager {
Cache getCache(String var1);//按照String类型的名字获取缓存
Collection<String> getCacheNames();//获取当前缓存管理器管理的所有缓存的名字集合
}
org.springframework.cache.Cache
xxxxxxxxxx
public interface Cache {
String getName();//获取当前缓存的名字
Object getNativeCache();
Cache.ValueWrapper get(Object var1);//根据缓存的key从缓存中查询一个数据
<T> T get(Object var1, Class<T> var2);
<T> T get(Object var1, Callable<T> var2);
void put(Object var1, Object var2);//将key-value键值对保存至缓存中
Cache.ValueWrapper putIfAbsent(Object var1, Object var2);
void evict(Object var1);//根据key从缓存中移除一个数据
void clear();//清空整个缓存
public static class ValueRetrievalException extends RuntimeException {
private final Object key;
public ValueRetrievalException( Object key, Callable<?> loader, Throwable ex) {
super(String.format("Value for key '%s' could not be loaded using '%s'", key, loader), ex);
this.key = key;
}
public Object getKey() {
return this.key;
}
}
public interface ValueWrapper {
Object get();
}
}
org.springframework.cache.concurrent.ConcurrentMapCacheManager
缓存名字:缓存名字是缓存管理器要管理缓存组件,为了方便给每个组件起了一个名字,该名字就是缓存名字,一个缓存名字相当于给缓存数据划分了一个区,就像一个市里面的各个区,区里面的管理制度由市制定因此每个区的管理方法都是一样的,只是区里面的数据不一样;这样设计的好处是可以根据缓存名字一次性只清空某个区域下的全部缓存,这只是方便业务逻辑定义的一个缓存数据标识,不去指定也是可以的
xxxxxxxxxx
public class ConcurrentMapCacheManager implements CacheManager, BeanClassLoaderAware {
private final ConcurrentMap<String, Cache> cacheMap = new ConcurrentHashMap(16);
private boolean dynamic = true;
private boolean allowNullValues = true;
private boolean storeByValue = false;
private SerializationDelegate serialization;
public ConcurrentMapCacheManager() {
}
//ConcurrentMapCacheManager的构造方法要传参可变长度字符串缓存名字,缓存名字的概念是缓存管理器要管理缓存组件,为了方便给每个组件起了一个名字,该名字就是缓存名字,一个缓存名字相当于给缓存数据划分了一个区,就像一个市里面的各个区,区里面的管理制度由市制定因此每个区的管理方法都是一样的,只是区里面的数据不一样;这样设计的好处是可以根据缓存名字一次性只清空某个区域下的全部缓存,这只是方便业务逻辑定义的一个缓存数据标识,不去指定也是可以的
public ConcurrentMapCacheManager(String... cacheNames) {
this.setCacheNames(Arrays.asList(cacheNames));1️⃣ //构造器会调用setCacheNames方法
}
1️⃣ //如果缓存不为空就会遍历每个缓存名字,以缓存名字作为key,以缓存Cache对象作为value,向ConcurrentMap<String,Cache>类型的cacheMap属性中添加缓存区域[这个Cache叫缓存组件],对应的缓存Cache通过方法createConcurrentMapCache(name)来创建,
public void setCacheNames( Collection<String> cacheNames) {
if (cacheNames != null) {
Iterator var2 = cacheNames.iterator();
while(var2.hasNext()) {
String name = (String)var2.next();
this.cacheMap.put(name, this.createConcurrentMapCache(name));1️⃣-1️⃣
}
this.dynamic = false;
} else {
this.dynamic = true;
}
}
public void setAllowNullValues(boolean allowNullValues) {
if (allowNullValues != this.allowNullValues) {
this.allowNullValues = allowNullValues;
this.recreateCaches();
}
}
public boolean isAllowNullValues() {
return this.allowNullValues;
}
public void setStoreByValue(boolean storeByValue) {
if (storeByValue != this.storeByValue) {
this.storeByValue = storeByValue;
this.recreateCaches();
}
}
public boolean isStoreByValue() {
return this.storeByValue;
}
public void setBeanClassLoader(ClassLoader classLoader) {
this.serialization = new SerializationDelegate(classLoader);
if (this.isStoreByValue()) {
this.recreateCaches();
}
}
public Collection<String> getCacheNames() {
return Collections.unmodifiableSet(this.cacheMap.keySet());
}
public Cache getCache(String name) {
Cache cache = (Cache)this.cacheMap.get(name);
if (cache == null && this.dynamic) {
synchronized(this.cacheMap) {
cache = (Cache)this.cacheMap.get(name);
if (cache == null) {
cache = this.createConcurrentMapCache(name);
this.cacheMap.put(name, cache);
}
}
}
return cache;
}
private void recreateCaches() {
Iterator var1 = this.cacheMap.entrySet().iterator();
while(var1.hasNext()) {
Entry<String, Cache> entry = (Entry)var1.next();
entry.setValue(this.createConcurrentMapCache((String)entry.getKey()));
}
}
1️⃣-1️⃣ //即初始化时根据传参的缓存名字创建一个缓存对象,对应ConcurrentMapCacheManager创建的Cache对象类型是ConcurrentMapCache,在该缓存组件会定义对缓存的增删改查操作
protected Cache createConcurrentMapCache(String name) {
SerializationDelegate actualSerialization = this.isStoreByValue() ? this.serialization : null;
return new ConcurrentMapCache(name, new ConcurrentHashMap(256), this.isAllowNullValues(), actualSerialization);
}
}
org.springframework.cache.concurrent.ConcurrentMapCache
该缓存组件会定义对缓存的增删改查操作
xxxxxxxxxx
public class ConcurrentMapCache extends AbstractValueAdaptingCache {
private final String name;
private final ConcurrentMap<Object, Object> store;//store属性是缓存组件存储所有数据的地方,所有的缓存数据按照键值对的形式存入这个ConcurrentMap中,增删改查数据都是对store的增删改查操作
private final SerializationDelegate serialization;
public ConcurrentMapCache(String name) {
this(name, new ConcurrentHashMap(256), true);
}
public ConcurrentMapCache(String name, boolean allowNullValues) {
this(name, new ConcurrentHashMap(256), allowNullValues);
}
public ConcurrentMapCache(String name, ConcurrentMap<Object, Object> store, boolean allowNullValues) {
this(name, store, allowNullValues, (SerializationDelegate)null);
}
protected ConcurrentMapCache(String name, ConcurrentMap<Object, Object> store, boolean allowNullValues, SerializationDelegate serialization) {
super(allowNullValues);
Assert.notNull(name, "Name must not be null");
Assert.notNull(store, "Store must not be null");
this.name = name;
this.store = store;
this.serialization = serialization;
}
public final boolean isStoreByValue() {
return this.serialization != null;
}
public final String getName() {
return this.name;
}
public final ConcurrentMap<Object, Object> getNativeCache() {
return this.store;
}
//lookup方法根据key从缓存组件中获取数据
protected Object lookup(Object key) {
return this.store.get(key);
}
public <T> T get(Object key, Callable<T> valueLoader) {
return this.fromStoreValue(this.store.computeIfAbsent(key, (k) -> {
try {
return this.toStoreValue(valueLoader.call());
} catch (Throwable var5) {
throw new ValueRetrievalException(key, valueLoader, var5);
}
}));
}
public void put(Object key, Object value) {
this.store.put(key, this.toStoreValue(value));
}
public ValueWrapper putIfAbsent(Object key, Object value) {
Object existing = this.store.putIfAbsent(key, this.toStoreValue(value));
return this.toValueWrapper(existing);
}
public void evict(Object key) {
this.store.remove(key);
}
public void clear() {
this.store.clear();
}
protected Object toStoreValue( Object userValue) {
Object storeValue = super.toStoreValue(userValue);
if (this.serialization != null) {
try {
return this.serializeValue(this.serialization, storeValue);
} catch (Throwable var4) {
throw new IllegalArgumentException("Failed to serialize cache value '" + userValue + "'. Does it implement Serializable?", var4);
}
} else {
return storeValue;
}
}
private Object serializeValue(SerializationDelegate serialization, Object storeValue) throws IOException {
ByteArrayOutputStream out = new ByteArrayOutputStream();
byte[] var4;
try {
serialization.serialize(storeValue, out);
var4 = out.toByteArray();
} finally {
out.close();
}
return var4;
}
protected Object fromStoreValue( Object storeValue) {
if (storeValue != null && this.serialization != null) {
try {
return super.fromStoreValue(this.deserializeValue(this.serialization, storeValue));
} catch (Throwable var3) {
throw new IllegalArgumentException("Failed to deserialize cache value '" + storeValue + "'", var3);
}
} else {
return super.fromStoreValue(storeValue);
}
}
private Object deserializeValue(SerializationDelegate serialization, Object storeValue) throws IOException {
ByteArrayInputStream in = new ByteArrayInputStream((byte[])((byte[])storeValue));
Object var4;
try {
var4 = serialization.deserialize(in);
} finally {
in.close();
}
return var4;
}
}
缓存分区组件操作缓存的过程
CacheManager
[RedisCacheManager]创建Cache
[RedisCache]负责缓存的读写
缓存未建立情况下首次查询数据并构建缓存流程
从第一次构建缓存的流程可以看出来,第一次调用redisCache.lookup(key)
方法查询缓存未命中和执行完业务方法对返回结果调用redisCache.put(key,value)
方法进行缓存,期间的所有方法都是没有加锁的,RedisCache
类中只有get(key)
方法加了本地锁,因此默认重建缓存过程是没有加锁的,因此使用SpringCache
是会存在缓存击穿问题的;
解决方法一是不使用SpringCache
自己手写使用分布式双重检查锁重建缓存;
解决方法二是将SpringCache
的@Cacheable
注解的sync
属性改为true
,默认值为false
,该属性的作用是同步潜在的几个调用该注解标注方法尝试获取同一个key
的结果的线程,说人话就是缓存没命中构建缓存时让构建缓存的线程同步,注意这里加的是本地锁
xxxxxxxxxx
spring-data-redis:2.1.10.RELEASE
//CacheAspectSupport类是缓存切面支持器,缓存所有功能都是拿AOP做的
--------------------------------------------------------------------------------------------------
CacheAspectSupport.execute
private Object execute(CacheOperationInvoker invoker, Method method, CacheAspectSupport.CacheOperationContexts contexts) {
if (contexts.isSynchronized()) {
CacheAspectSupport.CacheOperationContext context = (CacheAspectSupport.CacheOperationContext)contexts.get(CacheableOperation.class).iterator().next();
if (this.isConditionPassing(context, CacheOperationExpressionEvaluator.NO_RESULT)) {
Object key = this.generateKey(context, CacheOperationExpressionEvaluator.NO_RESULT);
Cache cache = (Cache)context.getCaches().iterator().next();
try {
return this.wrapCacheValue(method, cache.get(key, () -> {
return this.unwrapReturnValue(this.invokeOperation(invoker));
}));
} catch (ValueRetrievalException var10) {
throw (ThrowableWrapper)var10.getCause();
}
} else {
return this.invokeOperation(invoker);
}
} else {
this.processCacheEvicts(contexts.get(CacheEvictOperation.class), true, CacheOperationExpressionEvaluator.NO_RESULT);
ValueWrapper cacheHit = this.findCachedItem(contexts.get(CacheableOperation.class));1️⃣ //第一次获取缓存,通过返回值是否有值来检查缓存是否命中
List<CacheAspectSupport.CachePutRequest> cachePutRequests = new LinkedList();
if (cacheHit == null) {
this.collectPutRequests(contexts.get(CacheableOperation.class), CacheOperationExpressionEvaluator.NO_RESULT, cachePutRequests);//
}
Object cacheValue;
Object returnValue;
if (cacheHit != null && !this.hasCachePut(contexts)) {
cacheValue = cacheHit.get();
returnValue = this.wrapCacheValue(method, cacheValue);
} else {
returnValue = this.invokeOperation(invoker);//这一步进去就是执行用户定义的目标方法即业务逻辑并获取返回值赋值给returnValue
cacheValue = this.unwrapReturnValue(returnValue);//将方法返回值包装成缓存数据对象
}
this.collectPutRequests(contexts.get(CachePutOperation.class), cacheValue, cachePutRequests);2️⃣ //在这一步中将方法的返回值调用RedisCache.put()方法将缓存放入缓存媒介中,这儿一步到位跳到RedisCache的put方法
Iterator var8 = cachePutRequests.iterator();
while(var8.hasNext()) {
CacheAspectSupport.CachePutRequest cachePutRequest = (CacheAspectSupport.CachePutRequest)var8.next();
cachePutRequest.apply(cacheValue);
}
this.processCacheEvicts(contexts.get(CacheEvictOperation.class), false, cacheValue);
return returnValue;
}
}
1️⃣ CacheAspectSupport.findCachedItem(contexts.get(CacheableOperation.class))
private ValueWrapper findCachedItem(Collection<CacheAspectSupport.CacheOperationContext> contexts) {
Object result = CacheOperationExpressionEvaluator.NO_RESULT;
Iterator var3 = contexts.iterator();
while(var3.hasNext()) {
CacheAspectSupport.CacheOperationContext context = (CacheAspectSupport.CacheOperationContext)var3.next();
if (this.isConditionPassing(context, result)) {
Object key = this.generateKey(context, result);
ValueWrapper cached = this.findInCaches(context, key);1️⃣-1️⃣ //调用findInCaches第一次查询缓存,如果不是空直接返回查询到的缓存,如果是空检查是否需要打印日志并返回空值
if (cached != null) {
return cached;
}
if (this.logger.isTraceEnabled()) {
this.logger.trace("No cache entry for key '" + key + "' in cache(s) " + context.getCacheNames());
}
}
}
return null;
}
2️⃣ redisCache.put()
/*
* (non-Javadoc)
* @see org.springframework.cache.Cache#put(java.lang.Object, java.lang.Object)
*/
public void put(Object key, Object value) {
Object cacheValue = preProcessCacheValue(value);
if (!isAllowNullValues() && cacheValue == null) {
throw new IllegalArgumentException(String.format(
"Cache '%s' does not allow 'null' values. Avoid storing null via '@Cacheable(unless=\"#result == null\")' or configure RedisCache to allow 'null' via RedisCacheConfiguration.",
name));
}
cacheWriter.put(name, createAndConvertCacheKey(key), serializeCacheValue(cacheValue), cacheConfig.getTtl());
}
1️⃣-1️⃣ CacheAspectSupport.findInCaches(context, key)
private ValueWrapper findInCaches(CacheAspectSupport.CacheOperationContext context, Object key) {
Iterator var3 = context.getCaches().iterator();
Cache cache;
ValueWrapper wrapper;
do {
if (!var3.hasNext()) {
return null;
}
cache = (Cache)var3.next();
wrapper = this.doGet(cache, key);1️⃣-1️⃣-1️⃣ //调用doGet方法从缓存媒介中拿缓存赋值给wrapper,如果wrapper不为null则直接返回,如果为null,检查是否需要打印日志,然后直接返回null
} while(wrapper == null);
if (this.logger.isTraceEnabled()) {
this.logger.trace("Cache entry for key '" + key + "' found in cache '" + cache.getName() + "'");
}
return wrapper;
}
1️⃣-1️⃣-1️⃣ AbstractCacheInvoker.doGet(cache, key)
protected ValueWrapper doGet(Cache cache, Object key) {
try {
return cache.get(key);1️⃣-1️⃣-1️⃣-1️⃣ //根据缓存的key尝试获取缓存
} catch (RuntimeException var4) {
this.getErrorHandler().handleCacheGetError(var4, cache, key);
return null;
}
}
1️⃣-1️⃣-1️⃣-1️⃣ AbstractValueAdaptingCache.get(key)
public ValueWrapper get(Object key) {
Object value = this.lookup(key);1️⃣-1️⃣-1️⃣-1️⃣-1️⃣ //从这儿进入Cache组件的lookup方法尝试获取缓存数据
return this.toValueWrapper(value);
}
1️⃣-1️⃣-1️⃣-1️⃣-1️⃣ redisCache.lookup(key)
/*
* (non-Javadoc)
* @see org.springframework.cache.support.AbstractValueAdaptingCache#lookup(java.lang.Object)
*/
protected Object lookup(Object key) {//查询缓存的方法
byte[] value = cacheWriter.get(name, createAndConvertCacheKey(key));//先从缓存中获取对应key的缓存数据,当缓存中没有数据时该方法会返回null,这是构建缓存执行业务方法前第一次尝试从缓存媒介中获取缓存
if (value == null) {
return null;//返回null的情况下lookup方法就执行结束了
}
return deserializeCacheValue(value);
}
开启@Cacheable
注解的sync
功能后的缓存未建立情况下首次查询数据并构建缓存流程
sync
注解只有@Cacheable
注解有,其他的SpringCache
注解中是没有的,通过指定sync=true
给重建缓存加本地锁解决缓存击穿问题,加的不是完整的分布式锁,老师说加本地锁就足够使用了,但是这里还是感觉不完美,第一个问题是可能有双重检查锁但是源码分析没看见第一次检查的影子,如果没有双重检查锁那不是开启了sync的缓存查询每次查缓存都会上锁,因此这里倾向于使用了双重检查锁但是目前还没看出来;第二个问题是锁的粒度非常大,直接锁单例组件redisCache
,相当于直接锁服务实例,只要缓存重新构建就直接锁所有开启了sync的缓存的缓存重建过程
❓:这里有没有使用双重检查锁要好好验证一下,对性能影响挺大的
xxxxxxxxxx
spring-data-redis:2.1.10.RELEASE
--------------------------------------------------------------------------------------------------
cacheAspectSupport.execute()
private Object execute(CacheOperationInvoker invoker, Method method, CacheAspectSupport.CacheOperationContexts contexts) {
if (contexts.isSynchronized()) {//这里是判断@Cacheable注解的属性sync是否开启
CacheAspectSupport.CacheOperationContext context = (CacheAspectSupport.CacheOperationContext)contexts.get(CacheableOperation.class).iterator().next();
if (this.isConditionPassing(context, CacheOperationExpressionEvaluator.NO_RESULT)) {
Object key = this.generateKey(context, CacheOperationExpressionEvaluator.NO_RESULT);
Cache cache = (Cache)context.getCaches().iterator().next();
try {
return this.wrapCacheValue(method, cache.get(key, () -> {
return this.unwrapReturnValue(this.invokeOperation(invoker));//1️⃣ 封装一个异步查询业务方法结果的任务Callable<T>到execute方法的调用者,该调用者在调用redisCache.get()方法时将该任务传参进去了,并在缓存未命中的情况下发起异步任务,这个查询业务方法结果的方法其实上面未开启sync功能的流程已经说过了,就是这个方法下面的invokeOperation(invoker);并在此处通过cache.get(key,Callable<T>)调用RedisCache中的get方法
}));
} catch (ValueRetrievalException var10) {
throw (ThrowableWrapper)var10.getCause();
}
} else {
return this.invokeOperation(invoker);
}
} else {
this.processCacheEvicts(contexts.get(CacheEvictOperation.class), true, CacheOperationExpressionEvaluator.NO_RESULT);
ValueWrapper cacheHit = this.findCachedItem(contexts.get(CacheableOperation.class));
List<CacheAspectSupport.CachePutRequest> cachePutRequests = new LinkedList();
if (cacheHit == null) {
this.collectPutRequests(contexts.get(CacheableOperation.class), CacheOperationExpressionEvaluator.NO_RESULT, cachePutRequests);
}
Object cacheValue;
Object returnValue;
if (cacheHit != null && !this.hasCachePut(contexts)) {
cacheValue = cacheHit.get();
returnValue = this.wrapCacheValue(method, cacheValue);
} else {
returnValue = this.invokeOperation(invoker);
cacheValue = this.unwrapReturnValue(returnValue);
}
this.collectPutRequests(contexts.get(CachePutOperation.class), cacheValue, cachePutRequests);
Iterator var8 = cachePutRequests.iterator();
while(var8.hasNext()) {
CacheAspectSupport.CachePutRequest cachePutRequest = (CacheAspectSupport.CachePutRequest)var8.next();
cachePutRequest.apply(cacheValue);
}
this.processCacheEvicts(contexts.get(CacheEvictOperation.class), false, cacheValue);
return returnValue;
}
}
1️⃣ redisCache.get()
//这个有锁的get方法和我们以前写的逻辑是一样的,但是没有看到双重检查锁的影子,而且锁的粒度非常大,锁整个缓存的构建,而且如果外部没有第一次检查,这里每个开启sync的缓存查询都会被上锁,这肯定是没讲到
public synchronized <T> T get(Object key, Callable<T> valueLoader) {
ValueWrapper result = get(key);1️⃣-1️⃣ //第一次尝试从缓存中获取数据,注意因为是首次构建缓存,此时缓存中没有数据,该方法调用的是上面未开启功能时调用的从父类AbstractValueAdaptingCache继承来的1️⃣-1️⃣-1️⃣-1️⃣ AbstractValueAdaptingCache.get(key),这个方法实际上调用的是上面的1️⃣-1️⃣-1️⃣-1️⃣-1️⃣ redisCache.lookup(key)尝试去从缓存媒介获取缓存,但是因为是首次构建,所以此处会返回空值
//如果缓存被命中直接返回
if (result != null) {
return (T) result.get();
}
//如果缓存没有命中就去执行业务方法,获取返回值并调用将键值对存入调用redisCache.put(key,value)方法缓存媒介中
T value = valueFromLoader(key, valueLoader);1️⃣-2️⃣ //执行业务方法获取返回值
put(key, value);1️⃣-3️⃣ //调用redisCache.put方法将缓存数据键值对存入缓存媒介
return value;
}
1️⃣-1️⃣ AbstractValueAdaptingCache.get(key)
public ValueWrapper get(Object key) {
Object value = this.lookup(key);1️⃣-1️⃣-1️⃣ //从这儿进入Cache组件的lookup方法尝试获取缓存数据
return this.toValueWrapper(value);
}
1️⃣-2️⃣ redisCache.valueFromLoader(key, valueLoader)
private static <T> T valueFromLoader(Object key, Callable<T> valueLoader) {
try {
return valueLoader.call(); // 通过方法参数列表传参一个有返回结果的异步线程Callable的方式来调用业务方法,这里获取的返回值就是业务方法的返回值,该返回值返回给get方法
} catch (Exception e) {
throw new ValueRetrievalException(key, valueLoader, e);
}
}
1️⃣-3️⃣ redisCache.put(key, value)
public void put(Object key, Object value) {
Object cacheValue = preProcessCacheValue(value);
if (!isAllowNullValues() && cacheValue == null) {
throw new IllegalArgumentException(String.format(
"Cache '%s' does not allow 'null' values. Avoid storing null via '@Cacheable(unless=\"#result == null\")' or configure RedisCache to allow 'null' via RedisCacheConfiguration.",
name));
}
cacheWriter.put(name, createAndConvertCacheKey(key), serializeCacheValue(cacheValue), cacheConfig.getTtl());//将key和value序列化以后将数据存入缓存媒介
}
1️⃣-1️⃣-1️⃣ redisCache.lookup(key)
/*
* (non-Javadoc)
* @see org.springframework.cache.support.AbstractValueAdaptingCache#lookup(java.lang.Object)
*/
protected Object lookup(Object key) {//查询缓存的方法
byte[] value = cacheWriter.get(name, createAndConvertCacheKey(key));//先从缓存中获取对应key的缓存数据,当缓存中没有数据时该方法会返回null,这是构建缓存执行业务方法前第一次尝试从缓存媒介中获取缓存
if (value == null) {
return null;//返回null的情况下lookup方法就执行结束了
}
return deserializeCacheValue(value);
}
读取构建缓存时会涉及到缓存穿透、缓存雪崩、缓存击穿的问题
缓存穿透:恶意高并发查询一个不存在的数据,将查询压力给到数据库导致数据库崩掉,SpringCache
给出了缓存空值的解决方案
缓存雪崩:大量的key同时过期,同时大量的并发请求因为获取不到缓存同时打到服务器上,此时就会发生缓存雪崩,自己管理缓存,我们通过加随机时间的方式来解决这个问题,但是老师说这种方式很容易弄巧成拙,老师给出的解释是不同有效期加上随机时间可能导致大量的缓存有效时间相同,我觉得这个解释有点牵强;SpringCache
中只要为每个数据指定有效期,每个缓存建立的时间是分散随机的,在有效期相同情况下,即使在大量分散并发请求下重建缓存的时间点也是随机的,因此不需要考虑给有效期加随机时间,而且老师说只有超大型系统才可能存在这种情况,在一般的系统中,只要不是十几万个key同时过期,即使是key同时过期了,而且所有key的请求都访问数据库重建缓存,就不会对数据库造成不可挽回的压力,因此一般系统中根本不需要考虑该问题
🔎:而且这里的有效时间设置的太粗暴,直接把所有缓存的有效时间都设置成一样的了
缓存击穿,大量并发请求同时来查询一个正好过期的数据,解决办法是加锁,但是这里暂时还没有处理添加了SpringCache
以后的逻辑,应该加个双重检查锁也一样的,但是如何保证使用SpringCache以后判断完双重检查锁直接拿缓存且避免用拿到的缓存再更新缓存的问题呢
写数据库时考虑缓存数据一致性的问题
🔎:对于写数据库时缓存数据的一致性问题,SpringCache根本没管,需要根据业务场景自己设置,还是提供了一个很垃圾的缓存有效时间来实现缓存数据的最大更新间隔时间来解决写模式的数据一致性问题,常规数据使用SpringCache主要优势在简化开发、方便缓存管理,像缓存分区这种相关功能都是很好用的,自己实现起来就比较麻烦,对于特殊数据再考虑单独设计
对要求数据强一致性的读多写少场景直接加分布式读写锁
引入Canal感知mysql
的更新去更新缓存
读多写多的场景直接去查询数据库
总结
常规数据[读多写少,实时性、缓存数据一致性要求不高的数据],可以直接使用SpringCache
,常规数据的写模式数据一致性通过SpringCache
的缓存有效时间简单控制即可
特殊数据[实时性、一致性要求高的数据],想要通过加缓存提升速度,还想要保证一致性,就需要特殊设计[比如引入Canal、加读写锁、公平锁、可重入锁、信号量、闭锁等结合业务单独设计]
对于商品分类菜单数据,我们只需要考虑解决读模式下的缓存穿透、雪崩和击穿问题,即使用SpringCache
管理缓存即可
浏览器访问认证服务进行登录,登录后我们将用户的登录信息存入Redis
,并给浏览器下发JSESSIONID
的cookie
,将该cookie
的Domain
作用域从子域名auth.earlmall.com
改为顶级域名earlmall.com
来放大作用域实现cookie
在同一个顶级域名下所有请求共享,所有的服务都使用JSESSIONID
从Redis
中取出session
中共享的数据,实现后端统一第三方存储session
,前端拿着用户凭证JSESSIONID
去各个服务从第三方中获取session
中的数据
使用SpringSession
来简化将session
数据统一存储到第三方Redis
以及解决session
跨域共享问题的开发
SpringSession项目官网通过spring官网--Projects--SpringSession--Learn可以找到SpringSession的官方文档,其中章节Samples and Guides示例和向导是SpringSession的快速上手指南,章节HttpSession Integration是SpringSession基于各种第三方存储介质的整合
SpringBoot
基于Redis
整合SpringSession
解决session跨域跨服务共享问题
🔎:在Samples and Guides中找到并点击HttpSession with Redis,可以找到对应的使用引导
引入依赖
xxxxxxxxxx
<dependency>
<groupId>org.springframework.session</groupId>
<artifactId>spring-session-data-redis</artifactId>
</dependency>
检查一下SpringSession
操作redis
需不需要引入org.springframework.boot:spring-boot-starter-data-redis
,经过验证,SpringSession
依赖于org.springframework.data:spring-data-redis
,所以无需再额外引入
xxxxxxxxxx
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>
为了性能我们可以把默认的lettuce-core
排除掉使用最新的io.lettuce:lettuce-core:5.2.0.RELEASE
,老版本对内存管理存在问题,并发量一高就会大量抛异常
xxxxxxxxxx
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis</artifactId>
<exclusions>
<exclusion>
<groupId>io.lettuce</groupId>
<artifactId>lettuce-core</artifactId>
</exclusion>
</exclusions>
</dependency>
<!-- https://mvnrepository.com/artifact/io.lettuce/lettuce-core -->
<dependency>
<groupId>io.lettuce</groupId>
<artifactId>lettuce-core</artifactId>
<version>5.2.0.RELEASE</version>
</dependency>
SpringBoot
对SpringSession
的配置
选择session
的存储介质为redis
[必选]
xxxxxxxxxx
spring.session.store-type=redis
配置session
的超时时间[可选,默认配置是30分钟] [默认单位是秒,要指定分钟可以指定成如下格式30m]
xxxxxxxxxx
server.servlet.session.timeout=30m
配置redis
中session
的刷新策略[可选]
xxxxxxxxxx
spring.session.redis.flush-mode=on-save
配置redis
中session
存储的前缀[可选] [SpringSession
创建的缓存也和使用SpringCache
创建的缓存一样会创建相应的目录来管理]
xxxxxxxxxx
spring.session.redis.namespace=spring:session
SpringBoot
配置Redis
的连接信息
这个一般在项目中使用Redis
就会主动配置
xxxxxxxxxx
spring.redis.host = localhost #redis服务器主机IP地址
spring.redis.password = xxxx #redis服务器的登录密码
spring.redis.port = 6379 #redis服务器的端口号
Servlet
容器初始化原理
SpringBoot
配置好了一个名为SpringSessionRepositoryFilter
的组件,该组件实现了Filter
接口,相当于该组件具备过滤器的功能,这个SpringSessionRepositoryFilter
将原生HttpSession
替换成我们Spring
的自定义的session
实现
配置组件
xxxxxxxxxx
public class Config {
public LettuceConnectionFactory connectionFactory(){
return new LettuceConnectionFactory();
}
}
组件RedisConnectionFactory
已经被SpringBoot
自动注入到IoC
容器中,
我们只需要在配置类或者启动类上添加注解@EnableRedisHttpSession
开启整合Redis
作为session
存储的功能
卧槽这么牛皮,Spring
用自定义session
取代了原来Tomcat
自带的HttpSession
,我们原来操作session
都是直接在控制器方法的参数列表指定HttpSession
,Spring
容器自动进行注入tomcat
原生的HttpSession
,我们使用Tomcat
原生的HttpSession
的API来操作Session
,现在Spring
使用自定义的SpringSessionRepositoryFilter
来替换Tomcat
原生的HttpSession
,我们在控制器方法注入HttpSession
时会自动注入SpringSessionRepositoryFilter
,而且SpringSessionRepositoryFilter
操作session
的api
和HttpSession
是一样的,这意味着我们可以不需要更改代码只需要配置SpringSession
就能丝滑使用SpringSession
替代Tomcat
的原生HttpSession
,原来对session
的操作一样生效,只是更换了方法的具体实现,把session
存到redis
中去了,下发cookie
的时候也将对应的作用域设置为了顶级域名,这个就是Java
中多态的思想,猜测HttpSession
是一个接口,Tomcat
的原生HttpSession
只是其中一个实现类
经过确认,确实如此,javax.servlet.http.HttpSession
是tomcat-embed-core:9.0.24
中的包下的一个接口,下面有多个实现类,Tomcat
默认使用的是StandardSession
做完以上的步骤,在执行操作session
的方法时仍然会报错SerializationException
,原因是在执行session
操作的时候无法进行序列化,这是因为我们要操作一个对象,将对象从当前服务器内存中保存到第三方存储介质中,这个过程涉及到IO过程,暂时认为所有的IO过程都要对内存中的对象进行序列化后才能传输,序列化的目的是将一个内存中的对象序列化为二进制流或者串,我这里先肤浅地认为只有二进制流或者串才能执行IO操作,具体的原理以前讲的很浅,后面看JavaIO
中有没有补充
核心就是要使用SpringSession
操作的数据因为要存储到第三方公共存储介质中,需要被操作的数据能够被序列化,SpringSession
默认使用JDK序列化,JDK的默认序列化需要被序列化的对象对应的类实现序列化接口才能将对应的对象进行序列化
注意RedisTemplate
的序列化实现好像使用的是注入序列化器来对缓存的数据专门进行序列化,那个好像没有专门要求被缓存的数据需要实现序列化接口
xxxxxxxxxx
package com.earl.common.vo;
import lombok.Data;
import java.io.Serializable;
import java.util.Date;
/**
* @author Earl
* @version 1.0.0
* @描述 用户基础信息
* @创建日期 2024/10/17
* @since 1.0.0
*/
public class UserBaseInfoVo implements Serializable {
/**
* id
*/
private Long id;
/**
* 会员等级id
*/
private Long levelId;
/**
* 用户名
*/
private String username;
/**
* 昵称
*/
private String nickname;
/**
* 头像
*/
private String header;
/**
* 性别
*/
private Integer gender;
/**
* 个性签名
*/
private String sign;
/**
* 积分
*/
private Integer integration;
/**
* 成长值
*/
private Integer growth;
/**
* 注册时间
*/
private Date createTime;
}
SpringSession
在第一次执行session
操作后,会给客户端下发一个名为SESSION
的cookie
,该SESSION
令牌会替代原来的JSESSIONID
令牌
注意默认情况下,SpringSession
设置的作用域也是当前二级域名;
同时,跨服务使用SpringSession
基于Redis
来共享session
,两个服务都需要引入spring-session-data-redis
❓:我们在mall-auth
和mall-product
两个服务都引入spring-session-data-redis
,并将在mall-auth
即请求域名为auth.earlmall.com
下发的cookie
的作用域手动改成earlmall.com
,但是此时我们在mall-product
中获取mall-auth
存入的session
数据仍然报错SerializationException
,
🔑:经过分析,这是因为我们在mall-product
中要从redis
中获取被序列化的数据,并且要将该数据反序列化为对象,结果在反序列化的过程中在mall-product
中找不到数据对应的类,即SerializationException
是由ClassNotFoundException
导致的,因此需要在分布式集群中进行session
共享的类最好放在common
包下;
同时数据对象在被序列化的时候,会在序列化结果中保存序列化前的对象对应的全限定类名,因此直接将对应的类向使用session
数据的目标服务拷贝一份也是不行的,因为全限定类名不同,直接放在common
包下最保险,而且缓存中的全限定类名与实际类名不同,反序列化也会失败,实体类的全限定类名发生了变化一定要清空缓存
❓:目前使用SpringSession
基于公共第三方Redis
存储session
数据解决了session
跨服务共享的问题,但是目前存在两个问题,第一个问题是下发cookie
的作用域仍然是对应下发cookie
请求的二级域名,无法解决子域session
共享问题,第二个问题是SpringSession
默认使用的是JDK
自带的序列化器,我们希望能够使用字符串序列化器将对象序列化为json
对象存储在Redis
中,这样也方便我们自己查看一些出问题的session
数据
🔑:我们可以通过自定义SpringSession
来解决该问题
主要是配置JSON
序列化器和修改下发cookie
的作用域
使用JSON
序列化器来序列化存储的数据的快速使用文档参考章节Samples and Guides示例和向导中的HttpSession with Redis JSON serialization,查看配置文件发现没有多余的配置,给的代码中的SessionConfig.java
是SpringSession使用JSON
序列化器的相关配置
更改下发cookie
的作用域需要使用CookieSerializer
,相关的参考文档在spring官网--Projects--SpringSession--Learn--API Documentation的最后一个Using CookieSerializer,这个文档有点东西啊,唉,怎么这么累啊;改作用域相当于需要自定义cookie
,通过暴露CookieSerializer
作为容器组件,这里没讲清楚CookieSerializer
系列化器与设置cookie
参数的关系,先记着吧,迟早要读文档的
SessionConfig.java
通过给容器中注入RedisSerializer
替换掉SpringSession
默认的序列化机制就能把原来使用JDK
默认的序列化器换成JSON
序列化器
使用了自定义JSON
序列化器,不使用JDK默认的序列化器,实体类可以无需实现Serializable
接口
xxxxxxxxxx
/*
* Copyright 2014-2019 the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package sample.config;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.springframework.beans.factory.BeanClassLoaderAware;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.data.redis.serializer.GenericJackson2JsonRedisSerializer;
import org.springframework.data.redis.serializer.RedisSerializer;
import org.springframework.security.jackson2.SecurityJackson2Modules;
/**
* @author jitendra on 3/3/16.
*/
// tag::class[]
public class SessionConfig implements BeanClassLoaderAware {
private ClassLoader loader;
public RedisSerializer<Object> springSessionDefaultRedisSerializer() {
return new GenericJackson2JsonRedisSerializer(objectMapper());
}
/**
* Customized {@link ObjectMapper} to add mix-in for class that doesn't have default
* constructors
* @return the {@link ObjectMapper} to use
*/
private ObjectMapper objectMapper() {
ObjectMapper mapper = new ObjectMapper();
mapper.registerModules(SecurityJackson2Modules.getModules(this.loader));
return mapper;
}
/*
* @see
* org.springframework.beans.factory.BeanClassLoaderAware#setBeanClassLoader(java.lang
* .ClassLoader)
*/
public void setBeanClassLoader(ClassLoader classLoader) {
this.loader = classLoader;
}
}
// end::class[]
实际上雷神没有像上面一样配置的这么麻烦,下面是雷神配置示例
弹幕说:序列化不生效的注意,新版本需要将bean的方法名改为springSessionDefaultRedisSerializer
xxxxxxxxxx
public class EarlmallSessionConfig{
public RedisSerializer<Object> springSessionDefaultRedisSerializer() {
return new GenericJackson2JsonRedisSerializer();
}
}
配置CookieSerializer
配置cookie
的最大有效时间[默认配置是Session
,即浏览器一关cookie就失效]
xxxxxxxxxx
cookieSerializer.setCookieMaxAge(int cookieMaxAge)
配置cookie
的作用域为顶级域名[默认配置是二级域名,我们手动扩大这个作用域来实现session跨域共享]
xxxxxxxxxx
serializer.setDomainNamePattern("^.+?\\.(\\w+\\.[a-z]+)$");
配置第一次使用session
默认下发cookie
的名字
xxxxxxxxxx
serializer.setCookieName("JSESSIONID");
这个待补充
xxxxxxxxxx
serializer.setCookiePath("/");
配置示例
SpringSession
基于Redis
的配置比较麻烦啊,因为SpringSession
要使用Redis
,但是有些服务不需要使用Redis
。所以需要使用Session
数据的服务就需要搭建SpringSession
和Redis
的环境,如果直接配置在Common
包下也会显得比较臃肿
xxxxxxxxxx
public class EarlmallSessionConfig{
public CookieSerializer cookieSerializer() {
DefaultCookieSerializer cookieSerializer = new DefaultCookieSerializer();
cookieSerializer.setCookieName("EARLSESSIONID");
cookieSerializer.setDomainNamePattern("earlmall.com");
return cookieSerializer;
}
public RedisSerializer<Object> springSessionDefaultRedisSerializer() {
return new GenericJackson2JsonRedisSerializer();
}
}
@EnableRedisHttpSession
的原理
使用SpringSession
除了能非侵入性实现分布式跨服务跨域session
共享外还考虑了很多边缘问题,比如只要session
中的数据被使用了就会自动为session
中对应的数据自动续期
@EnableRedisHttpSession
注意:在spring-session 2.2.1
版本的时候,放入的不是RedisOperationsSessionRepository
了,而是RedisIndexedSessionRepository
核心原理是@EnableRedisHttpSession
注解导入配置类RedisHttpSessionConfiguration
,该配置类给IoC
容器中注入了一个基于Redis
增删改查session
的持久化层组件RedisOperationsSessionRepository
,该配置类还继承自类SpringHttpSessionConfiguration
,在该父配置类中给容器注入了一个SessionRepositoryFilter
即session
存储过滤器,该组件的父类OncePerRequestFilter
实现了Filter
接口,该session存储过滤器在构造完成时就会将RedisOperationsSessionRepository
注入成为属性完成初始化,通过父类OncePerRequestFilter
实现的doFilter()
方法调用SessionRepositoryFilter
自己实现的doFilterInterval()
方法,在该方法中即前置过滤器链中将RedisOperationsSessionRepository
放到本次请求的请求域中,并将原生的HttpServletRequest
和HttpServletResponse
和应用上下文ServletContext
包装成相应的请求包装类SessionRepositoryFilter<S>.SessionRepositoryRequestWrapper
和响应包装类SessionRepositoryFilter.SessionRepositoryResponseWrapper
,并在放行过滤器链的filterChain.doFilter(wrappedRequest, wrappedResponse);
方法中传参对应的请求和响应包装类,即后续的过滤器链和业务方法都是处理的请求和响应的包装类,我们在控制器方法中获取的HttpSession
组件本质是Spring
通过httpServletRequest.getSession()
方法获取的session
对象,当我们使用SpringSession
并使用注解@EnableRedisHttpSession
开启SpringSession
功能后就在前置过滤器链中将原生的HttpServletRequest
替换成了同样实现了HttpServletRequest
接口的包装类SessionRepositoryFilter<S>.SessionRepositoryRequestWrapper
,实际上调用的getSession
方法是包装类实现的,在该方法中通过持久化层组件RedisOperationsSessionRepository
来实现对session
的基于Redis
的持久化操作并最终得到session
对象
持久化层SessionRepository
也是一个接口,我们使用的第三方存储介质是redis,而且导入的是基于redis
的SpringSession场景启动器spring-session-data-redis
,因此默认使用的是子接口FindByIndexNameSessionRepository
下的唯一实现类RedisOperationsSessionRepository
,此外还有直接实现类MapSessionRepository
使用内存来保存session;此外如果我们导入基于JDBC的SpringSession
场景启动器还可以使用数据库来保存session,如果导入基于MongoDB
的SpringSession
场景启动器我们也可以使用MongoDB
来保存session,也会有相应的数据库持久层
以上原理就是典型的装饰者模式的应用,实现代码的非侵入性修改
xxxxxxxxxx
RetentionPolicy.RUNTIME) (
ElementType.TYPE}) ({
RedisHttpSessionConfiguration.class})//1️⃣ @EnableRedisHttpSession注解为容器导入了配置类RedisHttpSessionConfiguration ({
public @interface EnableRedisHttpSession {
int maxInactiveIntervalInSeconds() default 1800;
String redisNamespace() default "spring:session";
RedisFlushMode redisFlushMode() default RedisFlushMode.ON_SAVE;
String cleanupCron() default "0 * * * * *";
}
1️⃣
public class RedisHttpSessionConfiguration extends SpringHttpSessionConfiguration implements BeanClassLoaderAware, EmbeddedValueResolverAware, ImportAware, SchedulingConfigurer {//1️⃣-1️⃣ 配置类RedisHttpSessionConfiguration继承了SpringHttpSessionConfiguration
...
//向容器中添加组件RedisOperationsSessionRepository,这个组件从名字上能看出是基于Redis操作Session的数据化持久层,相当于基于Redis操作session的DAO,这个就是session增删改查的封装类,这里面定义了大量类似于getSession获取session,findById查找session,deleteById删除session的操作redis的大量增删改查方法
public RedisOperationsSessionRepository sessionRepository() {
RedisTemplate<Object, Object> redisTemplate = this.createRedisTemplate();
RedisOperationsSessionRepository sessionRepository = new RedisOperationsSessionRepository(redisTemplate);
sessionRepository.setApplicationEventPublisher(this.applicationEventPublisher);
if (this.defaultRedisSerializer != null) {
sessionRepository.setDefaultSerializer(this.defaultRedisSerializer);
}
sessionRepository.setDefaultMaxInactiveInterval(this.maxInactiveIntervalInSeconds);
if (StringUtils.hasText(this.redisNamespace)) {
sessionRepository.setRedisKeyNamespace(this.redisNamespace);
}
sessionRepository.setRedisFlushMode(this.redisFlushMode);
int database = this.resolveDatabase();
sessionRepository.setDatabase(database);
return sessionRepository;
}
...
}
1️⃣-1️⃣
public class SpringHttpSessionConfiguration implements ApplicationContextAware {
...
//该@PostConstruct注解的意思是只要类SpringHttpSessionConfiguration调用构造方法实例化以后就会立即执行该方法,该方法的作用是初始化cookieSerializer对象,如果我们自定义了cookieSerializer就使用我们自定义的,如果没有自定义就使用默认的CookieSerializer
public void init() {
CookieSerializer cookieSerializer = this.cookieSerializer != null ? this.cookieSerializer : this.createDefaultCookieSerializer();
this.defaultHttpSessionIdResolver.setCookieSerializer(cookieSerializer);
}
//SessionEventHttpSessionListenerAdapter是监听器,监听session相关的各种事件,比如服务器停机session的序列化和反序列化
public SessionEventHttpSessionListenerAdapter sessionEventHttpSessionListenerAdapter() {
return new SessionEventHttpSessionListenerAdapter(this.httpSessionListeners);
}
//1️⃣-1️⃣-1️⃣ 给容器中注入一个SessionRepositoryFilter即session存储过滤器,该过滤器实现了Servlet中的Filter接口,每个请求都会经过该过滤器进行相应请求处理
public <S extends Session> SessionRepositoryFilter<? extends Session> springSessionRepositoryFilter(SessionRepository<S> sessionRepository) {
SessionRepositoryFilter<S> sessionRepositoryFilter = new SessionRepositoryFilter(sessionRepository);
sessionRepositoryFilter.setServletContext(this.servletContext);
sessionRepositoryFilter.setHttpSessionIdResolver(this.httpSessionIdResolver);
return sessionRepositoryFilter;
}
...
}
1️⃣-1️⃣-1️⃣
-2147483598) (
//SessionRepositoryFilter继承了OncePerRequestFilter,OncePerRequestFilter实现了Filter接口,在OncePerRequestFilter中实现的doFilter方法中调用了抽象方法doFilterInternal,该方法被子类SessionRepositoryFilter实现,SpringSession的核心就是这个doFilterInternal方法
public class SessionRepositoryFilter<S extends Session> extends OncePerRequestFilter {
...
public SessionRepositoryFilter(SessionRepository<S> sessionRepository) {//sessionRepositoryFilter在构造的时候就自动注入上述操作session的持久化组件RedisOperationsSessionRepository
if (sessionRepository == null) {
throw new IllegalArgumentException("sessionRepository cannot be null");
} else {
this.sessionRepository = sessionRepository;
}
}
...
//SpringSession的核心原理就是该方法,即SessionRepositoryFilter中的doFilterInternal方法
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {
request.setAttribute(SESSION_REPOSITORY_ATTR, this.sessionRepository);//给请求域中存放操作session的持久层组件sessionRepository即此前的RedisOperationsSessionRepository,给请求域存在的数据可以在当次请求处理期间被到处共享
SessionRepositoryFilter<S>.SessionRepositoryRequestWrapper wrappedRequest = new SessionRepositoryFilter.SessionRepositoryRequestWrapper(request, response, this.servletContext);//将原生的请求、响应和servlet应用上下文包装成SessionRepositoryFilter<S>.SessionRepositoryRequestWrapper即一个包装请求对象,这是一个典型的装饰者模式
SessionRepositoryFilter.SessionRepositoryResponseWrapper wrappedResponse = new SessionRepositoryFilter.SessionRepositoryResponseWrapper(wrappedRequest, response);//将包装后的请求对象和原生的响应对象包装成一个包装响应对象SessionRepositoryFilter.SessionRepositoryResponseWrapper
try {
filterChain.doFilter(wrappedRequest, wrappedResponse);//1️⃣-1️⃣-1️⃣-1️⃣ 特别注意此处调用filterChain的doFilter方法放行的时候,传参不是原生的request和response,而是包装过的包装请求对象wrappedRequest和包装响应对象,整个过滤器链执行到此处,此前的过滤器链都是对原生的请求和响应进行处理,而后面的过滤器链包括业务方法都是对包装后的请求和响应进行处理,即将包装后的请求和响应对象全部应用到了整个执行链,这里直接跳过中间过程到控制器方法对包装请求和响应的处理
} finally {
wrappedRequest.commitSession();
}
}
...
private final class SessionRepositoryRequestWrapper extends HttpServletRequestWrapper {
...
private final class SessionCommittingRequestDispatcher implements RequestDispatcher {
...
}
private final class HttpSessionWrapper extends HttpSessionAdapter<S> {
...
}
}
private final class SessionRepositoryResponseWrapper extends OnCommittedResponseWrapper {
...
}
}
1️⃣-1️⃣-1️⃣-1️⃣
"/weibo/success") (
public String weiboAuthSuccessThen(String code,
HttpSession session,
RedirectAttributes attributes,
HttpServletRequest request){//实际上Spring向控制器方法中自动注入的HttpSession就是httpServletRequest.getSession(),即我们要获取session就会从请求中去获取session,但是我们的请求已经在过滤器链中被包装成了SessionRepositoryFilter<S>.SessionRepositoryRequestWrapper,因此Spring在使用SpringSession的情况下调用request.getSession()获取session实际上调用的包装类SessionRepositoryFilter<S>.SessionRepositoryRequestWrapper的getSession方法
try {
UserBaseInfoVo user = authService.weiboAuthSuccessThen(code);
HttpSession session1 = request.getSession();//1️⃣-1️⃣-1️⃣-1️⃣-1️⃣ 这里在使用SpringSession的时候实际调用的是wrappedRequest.getSession()
session.setAttribute("user",user);
return "redirect:http://earlmall.com";
}catch (RRException e){
attributes.addFlashAttribute("error",e.getCode()+":"+e.getMsg());
return "redirect:http://auth.earlmall.com/login.html";
}
}
1️⃣-1️⃣-1️⃣-1️⃣-1️⃣
private final class SessionRepositoryRequestWrapper extends HttpServletRequestWrapper {//SessionRepositoryRequestWrapper继承了HttpServletRequestWrapper,HttpServletRequestWrapper实现了HttpServletRequest
...
//这个就是SessionRepositoryRequestWrapper的getSession方法,
public SessionRepositoryFilter<S>.SessionRepositoryRequestWrapper.HttpSessionWrapper getSession(boolean create) {
SessionRepositoryFilter<S>.SessionRepositoryRequestWrapper.HttpSessionWrapper currentSession = this.getCurrentSession();//先通过getCurrentSession()获取当前的session即currentSession,如果能获取到则直接返回,猜测这里是懒惰初始化,第一次使用的时候创建对应的对象
if (currentSession != null) {
return currentSession;
} else {
//如果此前没有创建过session,即currentSession获取不到,则会调用getRequestedSession()方法
S requestedSession = this.getRequestedSession();
if (requestedSession != null) {
if (this.getAttribute(SessionRepositoryFilter.INVALID_SESSION_ID_ATTR) == null) {
requestedSession.setLastAccessedTime(Instant.now());
this.requestedSessionIdValid = true;
currentSession = new SessionRepositoryFilter.SessionRepositoryRequestWrapper.HttpSessionWrapper(requestedSession, this.getServletContext());
currentSession.setNew(false);
this.setCurrentSession(currentSession);
return currentSession;
}
} else {
if (SessionRepositoryFilter.SESSION_LOGGER.isDebugEnabled()) {
SessionRepositoryFilter.SESSION_LOGGER.debug("No session found by id: Caching result for getSession(false) for this HttpServletRequest.");
}
this.setAttribute(SessionRepositoryFilter.INVALID_SESSION_ID_ATTR, "true");
}
if (!create) {
return null;
} else {
if (SessionRepositoryFilter.SESSION_LOGGER.isDebugEnabled()) {
SessionRepositoryFilter.SESSION_LOGGER.debug("A new session was created. To help you troubleshoot where the session was created we provided a StackTrace (this is not an error). You can prevent this from appearing by disabling DEBUG logging for " + SessionRepositoryFilter.SESSION_LOGGER_NAME, new RuntimeException("For debugging purposes only (not an error)"));
}
S session = SessionRepositoryFilter.this.sessionRepository.createSession();
session.setLastAccessedTime(Instant.now());
currentSession = new SessionRepositoryFilter.SessionRepositoryRequestWrapper.HttpSessionWrapper(session, this.getServletContext());
this.setCurrentSession(currentSession);
return currentSession;
}
}
}
...
private S getRequestedSession() {
if (!this.requestedSessionCached) {
List<String> sessionIds = SessionRepositoryFilter.this.httpSessionIdResolver.resolveSessionIds(this);
Iterator var2 = sessionIds.iterator();
while(var2.hasNext()) {
String sessionId = (String)var2.next();
if (this.requestedSessionId == null) {
this.requestedSessionId = sessionId;
}
S session = SessionRepositoryFilter.this.sessionRepository.findById(sessionId);//这里调用sessionRepository即RedisOperationsSessionRepository,因此SpringSession对session的增删改查全部是通过Redis完成的
if (session != null) {
this.requestedSession = session;
this.requestedSessionId = sessionId;
break;
}
}
this.requestedSessionCached = true;
}
return this.requestedSession;
}
...
}
生产者发送消息给消息代理[RabbitMQ服务器]并指定消息的路由键,根据该路由key找到指定虚拟主机中的指定交换器,根据交换器和队列的绑定关系决定将消息保存到哪些队列,消费者通过监听对应队列,队列尾的内容被消费者通过信道实时拿到
概念介绍
Publisher消息生产者:是向交换器发布消息的客户端应用程序
Message消息:消息由消息头和消息体构成,
消息头中有很多的配置项
route-key
路由键:
priority
:相较于其他消息的优先权
delivery-mode
:配置消息是否需要持久性存储
消息体中是真正的消息内容
Broker消息服务器:也就是上面说的消息代理
Exchange交换器:这个交换器用来接收生产者传递过来的消息并将这些消息路由给服务器中的各种队列,交换器有4种类型,不同交换机转发消息的策略有区别,这个类似于网络交换机,一个消息服务器中可能存在多个不同类型的交换器,交换器接收生产者传递来的消息并按照既定策略将消息路由到指定的队列中,一个消息代理中可能含有多个消息队列,消息队列和交换器之间有预设的绑定关系Binding
Direct[默认]:直接交换器
其中Direct和Headers都是JMS中点对点通信模型的实现,Headers匹配AMQP消息的消息头而不是路由键,Headers交换器和Direct交换器除了匹配路由不同其他都完全一致,但是Headers的性能比较低下,一般都不讨论,也几乎不用;主要讨论Direct、Fanout和Topic
Direct Exchange直接交换器:该交换器将消息交给一个指定的队列,消息中的路由键routing key
只能唯一匹配与Bingding
中bingding key
完全相同的队列,比如一个路由键为dog
的消息只会被直接交换机路由到绑定关系中binding key
为dog
的队列,核心是routing key
和绑定关系中的binding key
一模一样才能进行匹配,这也叫完全匹配、单播模式,也称为点对点模式
Fanout:扇出交换器
Fanout和Topic都是发布订阅模式的实现,这种是广播模式的实现,无条件将消息发给所有与交换器绑定的队列
Fanout Exchange扇出交换器:这种交换器根本不关心交换器的路由键是什么,一个扇出交换器可以绑定多个队列,每个发送到扇出交换器的消息都会被广播到与扇出交换器绑定的所有队列上,很像子网广播,每台子网内的主机都会获得一份复制的消息,Fanout交换器转发消息是最快的
给扇出交换器发送的消息不指定routing key
也是可以的,所有和扇出交换器绑定的队列都能接收到消息;指定了路由键也不会对路由键进行判断处理
Topic:主题交换器,对应发布订阅模式,主题交换器对应的是根据路由键将消息路由到模式匹配的一个或者多个与交换器绑定的队列
一个主题交换器绑定多个队列,每个Bingding
中都有一个模式bingding key
,这个模式由两个通配符#
和*
以及单词和点构成,其中通配符#
匹配0个或者多个单词,注意不能使用#
匹配字母;通配符*
匹配一个单词即被匹配的路由键对应位置必须有一个单词,单词之间使用点进行分隔,只有路由键匹配对应绑定关系的bingding key
消息才会被转发到对应的一个或者多个队列上
比如bingding key=usa.#
即匹配rounting key
以单词usa
开头的,bingding key=#.news
匹配routing key
以news
作为后缀的
Headers:
Queue队列:是消息的容器,用于保存消息直到消费者连接该队列将该消息取走,一个消息可以投入一个或者多个队列
Binding绑定:用户关联消息队列和交换器,绑定是基于路由键将交换器和消息队列关联起来的路由规则,可以将交换器理解成一个由绑定构成的路由表,交换器和队列的绑定关系是多对多关系
Connection连接:每个客户端都只会和消息中间件建立一条长连接来收发消息,长连接就是一直保持连接状态的连接,连接类型是TCP连接,消费者可以通过该一条连接同时接收来自多个队列的消息
Consumer消费者:从消息队列中取得消息的客户端应用程序
Channel信道:Java的NIO中也有信道的概念,信道是多路复用连接中的一条独立的双向数据流通道,信道是建立在真实的TCP连接内的虚拟连接,AMQP命令、发布消息、订阅队列或者接收消息都是通过信道完成的,通过在一条长连接上开辟多条信道,每条信道负责各自的收发消息通信,接收消息是使用信道来接收指定队列的消息,对于操作系统来说建立和销毁TCP连接都是非常昂贵的开销,通过信道来实现对一条TCP连接的复用
同时通过长连接,一旦消费者宕机导致连接中断,我们的消息代理能够实时感知到消费者下线,消息无法被消费者获取,就会立即将消息存储起来不再向外派发,不会造成消息大面积丢失;如果消息代理不能实时识别消费者的连接状态,消费者宕机的情况下仍然将消息发送给消费者并删除对应消息,消息就丢失了
Virtual Host虚拟主机:虚拟主机标识一批交换器、消息队列和相关对象,虚拟主机的作用是将多个交换器、多个队列作为一个整体和其他的虚拟主机隔离开,避免一个虚拟主机由于一套系统的突发情况导致消息队列中间件崩溃同时影响到使用另一套虚拟主机的其他系统
虚拟主机以路径作为标识,不同的虚拟主机位于同一个消息服务器即Broker中,不同的虚拟主机相互隔离,在使用上就像在机器上安装了另外一台RabbitMQ服务器
这里老师用的是RabbitMQ3.8.2
安装步骤
使用命令docker run -d --name rabbitmq -p 5671:5671 -p 5672:5672 -p 4369:4369 -p 25672:25672 -p 15671:15671 -p 15672:15672 rabbitmq:management
安装RabbitMQ,其中
4369
、25672
端口是Erlang
发现端口和集群端口,RabbitMQ是用Erlang语言编写的
5672
、5671
是AMQP端口
15672
是web管理后台端口
61613
、61614
是STOMP协议端口,开启了STOMP协议才需要开放该端口
1883
、8883
是MQTT协议端口,开启了MQTT协议才需要开放该端口
RabbitMQ的RabbitMQ官方文档对这些端口有具体说明
使用命令docker update rabbitmq --restart=always
让容器随着Docker启动自动启动
使用命令docker exec -it rabbitmq /bin/bash
进入容器rabbitmq
的bash命令控制台,无需停止rabbitmq
直接使用命令rabbitmq-plugins enable rabbitmq_management
,也无需重启容器
后台管理系统http://192.168.56.10:15672
的默认账号和登录密码都是guest
,如果不执行上述步骤只能打开登录页无法登录进入首页
如何在docker内部使用命令停止rabbitmq
没学过,使用原生rpm
安装的的Rabbitmq
命令提示找不到对应服务
界面简介
Overview
:RabbitMQ服务器的运行状况概览,Web管理界面的数据每5秒刷新一次,默认访问的是所有虚拟主机,有一个默认的虚拟主机/
totals
:包含Queued messages
[消息队列中的消息]、Currently idle
[当前消息服务器中的空闲信息]、Message rates
[消息的收发速率]、Global counts
[监控的全局属性,包括有多少条连接,多少个信道、多少交换器、多少个队列以及多少个消费者]
Node
:列举RabbitMQ
的节点信息,因为当前不是集群,因此只列举了一个节点,展示了节点的内存、磁盘空间占用
Churn statics
:以图表的形式列举静态统计数据,每秒有多少个链接、多少个信道、多少个队列
Ports and contexts
:展示RabbitMQ的监听端口[比如客户端使用高级消息队列协议连接消息代理收发消息就要使用AMQP协议的通信端口5672
、集群端口25672
和Web端的端口15672
,注意Web上下文的端口也是15672,绑定了IP地址0.0.0.0
[意思是所有人都可以访问该Web端口]
Export definitions
:可以做老RabbitMQ服务器配置迁移,比如我们新装的RabbitMQ想使用已有配置可以直接在该选项中下载消息服务器配置下载相应配置文件
Import definitions
:可以在该选项卡中通过上传从别的RabbitMQ
集群中下载的配置文件,一键将已有配置应用到新的RabbitMQ
集群中来让多个集群或者机器保持相同的配置
Connections
:该选项卡监控当前RabbitMQ服务器有多少个客户端和服务器建立了连接,注意一个客户端只会和一个服务器建立一个连接
Channels
:一条连接会有多条信道,所有的信道都会在该选项卡中展示出来
Exchanges
:该选项卡会列举RabbitMQ中所有的交换器,默认的交换器有7个,并展示所有交换器的名字、类型、交换器特性、消息进出交换器的速率
Add a new exchange
:我们可以通过该选项卡创建新的自定义交换器
Publish message
:Web客户端实际上就是一个消息队列客户端,我们可以在web界面该选项卡直接发消息,Routing key
是消息的路由键,Payload
是消息具体内容
Queues
:该选项卡列举RabbitMQ
中的所有队列,Ready
是队列中准备被消费的消息数量、Unacked
是队列中还没有收到消费者消息确认的消息数量
队列列表中的Features
字段表示队列的配置信息,D
即Durable
表示当前队列是持久化的,DLX
表示当前队列设置了死信交换器,DLK
表示当前队列的死信消息被设置了路由键,Args
是当前队列被设置的其他参数比如x-message-ttl
,TTL
表示当前队列设置了消息的存活时间
Add a new queue
:我们可以通过该选项卡创建自定义的队列
点进具体的队列使用选项卡Get Message
能使用Web客户端获取指定队列中的消息
Ack Mode
:回复模式,选择Nack message requeue true
是当前Web客户端拿到消息后不告诉服务器自己拿到消息了,RabbitMQ
会将消息重新入队列,其中Nack
表示收到消息不回复,message requeue true
表示开启消息重新入队列
Admin
:这是RabbitMQ
的管理设置功能,我们可以通过选择右侧的选项卡在这里设置用户信息、虚拟主机信息[显示虚拟主机的消息、客户端、消息的获取派发速率等数据]、特性标识、配置策略、Limits
[对虚拟主机的连接限制,可以设置RabbitMQ
的最大连接数和最大队列数]、Cluster
展示和配置集群信息
Add a user
:我们可以通过该选项卡添加新的用户
Add a new virtual host
:虚拟主机是通过路径来区分的,我们可以通过该选项卡来添加新的虚拟主机,通过点击虚拟主机的名字我们还可以对虚拟主机进行更细致的配置[比如删除虚拟主机]
底部的选项卡会列举RabbitMQ
的一些官方文档和不太重要的信息,我们一般使用该界面来管理RabbitMQ
中的交换器和队列
这里主要介绍交换器和队列的使用方法
RabbitMQ的运行机制
一个交换器可能和多个队列都有绑定关系,一个队列也可以被多个交换器绑定;生产者将消息发布到交换器上,交换器根据绑定关系和消息的路由键决定将消息发送到指定的队列上,整个过程就是消息路由的过程
注意消息是发送给交换器,监听消息是监听交换器
默认交换器
RabbitMQ默认有七个交换器,其中两个直接交换器,一个扇出交换器、两个Headers交换器和两个主题交换器
创建交换器
创建交换器指定交换器的名字,交换器的类型、交换机是否持久化或者设置为临时,持久化的交换器在RabbitMQ服务器重启以后仍然存在,但是临时交换器只要RabbitMQ一重启就没了,
自动删除设置为YES当交换器没有任何队列绑定在该交换器上该交换器就会自动删除
Internal设置为yes即表示当前交换器为内部交换器,客户端不能给该交换器转发消息,内部交换器只是供RabbitMQ内部转发路由使用的
一般自动删除和内部交换器都设置为默认的No
通过交换器列表的名字点进交换器我们可以查看交换器更详细的绑定信息、消息发布信息,设置交换器的绑定关系
交换器可以和交换器进行绑定,交换器也可以和队列进行绑定,通过这种机制可以实现交换器绑定交换器再绑定到队列,实现多层路由
配置绑定关系指定的routing key
就是上面说的Binding
中的binding key
创建队列
创建队列,指定队列名字、指定队列是否持久化
如果队列自动删除设定为yes,只要没有消费者连接监听该队列,队列就会自动删除
将交换器与队列进行绑定并指定binding key
向交换器发送消息
使用RabbitMQ的延时队列可以实现定时任务的效果
场景
📜:下订单如果三十分钟以后没有支付就关单,锁定库存成以后四十分钟如果订单没有创建成功或者订单被取消就释放被锁定的库存
💡:方案一是系统使用定时任务每隔1分钟就去扫描数据库检查哪些订单还没有支付,如果其中有订单到期了就将订单删除;锁定库存四十分钟仍然有锁库存记录且订单没有被支付或者订单没有被创建就解锁库存
缺点:定时任务消耗系统内存,每隔一段时间就要全盘扫描一次增加数据库压力,定时任务最大的问题是有较大的时间误差,即我们开启定时任务的根据不是以每个业务作为起点的,而是以每个服务的某个系统时间作为起点的,但是业务的创建时间是随机的,我们只能通过逻辑判断业务是否在定时任务时刻满足到期条件,这不可避免地会导致业务实际到期时间出现偏差,偏差越小我们的定时任务就越频繁,定时任务对系统内存和数据库的压力就越大
💡:方案二是使用RabbitMQ的延时队列,延时队列是结合消息的存活时间TTL
和死信路由Exchange来结合实现的,我们创建订单成功可以给延时队列中存放一条消息,消息到达指定时间后被转发给监听队列的服务,即延时队列的消息最大的特点是消息在指定时间后才能被消费者接收到;锁顶库存成功了我们就给另一个延时时间40分钟的延时队列也发送一条锁定库存成功的消息,延时时间到了以后再给库存服务发送消息,库存服务拿到消息检查订单如果没有支付或者订单压根没有成功创建就去解锁被锁定的库存
延时队列实现的定时任务能解决系统定时任务带来的大量业务的时效性问题,延时队列的时效性只会因为网络波动重试等差上几秒钟,但是系统定时任务不仅占用系统和数据库资源,还会存在巨大的业务时效性问题
延时队列
消息的TTL
[Time To Live]:消息的存活时间,RabbitMQ可以给队列和消息都分别设置存活时间,不论给队列还是消息设置存活时间,存活时间的含义都是从消息进入队列开始到达存活时间消息仍然没有被消费者消费,消息就会变成死信,RabbitMQ服务器会默认将死信直接丢弃
对队列设置TTL是没有消费者连接时消息在队列中的最大保留时间
如果队列设置了TTL、同时消息也设置了TTL,会选取两者中小的TTL作为当前消息的TTL,这也意味着如果一个消息被路由到不同的队列中,这些消息的存活时间可能不会相同
消息的存活时间设置:通过设置消息的expiration
字段或者x-message-ttl
属性来设置消息的TTL,两种设置方式的效果是相同的
死信
一个消息满足以下条件就会进入一个死信路由,这个死信路由可以对应很多队列
消息被消费者拒收,并且手动消息确认时有一个reject
方法中的重新入队参数requeue
为false
,即消费者收到消息但是拒签消息而且标记了不让消息重新入队列
消息的存活时间到了,消息过期
队列的长度限制满了。排在前面的消息会被丢弃或者扔到死信路由上
死信交换器Dead letter Exchange
就是一种普通的交换器,只是一个队列设置了死信交换器,一旦消息过期就会自动触发消息转发到死信交换器中
延时队列的实现是设置一个队列中的消息存活时间为指定值,队列不能让任何消费者监听,让队列在消息有效时间内一直保存消息,消息一过期,让消息进入死信交换器,死信交换器再将消息路由到绑定了指定消费者的队列直接将消息转发给消费者,相当于在正常的消息转发路径上添加了一个没有消费者监听的队列来在指定时间内等待消息自动失效被死信队列转发
延时队列实现1:
消息生产者以deal.message
作为路由键发送消息给交换器x将消息路由到延迟队列delay Sm queue
,该队列相较于普通队列多了三项设置[队列消息存活时间x-message-ttl:300000
,单位是毫秒;设置死信交换器x-dead-letter-exchange:delay exchange
为交换器delay exchange
,即队列中的消息过期了自动转发给死信交换器delay exchange
;设置死信转发给死信交换器的路由键x-dead-letter-rounting-key:delay message
为delay message
,死信交换器会根据该路由键将死信转发到对应绑定键的队列test queue
中,并将消息发送给消费者]
延时队列实现2:
这个实现其实就是将上面给队列设置消息过期时间改成了单独给每个消息设置过期时间,消息生产者发送消息是给消息的expiration
字段设置expiration:300000
,将延迟队列delay Sm queue
设置死信交换器为delay exchange
,将路由键设置为delay.message
;消息过期以后将消息的路由键设置为delay.message
并转发给死信交换器delay exchange
,死信交换器根据路由键和绑定键将消息转发给消息队列test queue
,消息队列将消息发送给消费者
一般我们会采用给队列设置消息过期即方案1的方式,因为RabbitMQ采用的是惰性检查机制,也叫作懒检查,懒检查就是RabbitMQ只会在队列头消息过期的时间点来检查头节点的有效时间是否过期,过期了就将该消息作为死信;此时才会检查下一个消息是否过期,如果下一个消息早就过期了才会将消息设置为死信,但是给整个队列设置同一个过期时间就不会出现这种问题,因为是以消息到达队列时开始计算相同的过期时间,即消息头的节点没过期后续的节点永远不会过期
延时队列实现3:
在实现1的基础上我们可以简化为如下实现,即将两个交换器合并为一个交换器,根据消息前后的路由键不同由一个交换器将同一条消息分别路由到两个不同的消息队列中
以延时队列3为例通过向Spring容器中注入组件的方式来创建延时队列
一个交换器绑定多个队列使用路由键模糊匹配一般都使用主题交换器
xxxxxxxxxx
/**
* @author Earl
* @version 1.0.0
* @描述 自定义RabbitMQ配置
* 1. 使用@Bean注解注入容器的队列、交换器、绑定关系如果在RabbitMQ服务器中没有SpringBoot会自动在RabbitMQ服务器中进行创建
* @创建日期 2024/11/26
* @since 1.0.0
*/
public class CustomRabbitMQConfig {
/**
* @return {@link Queue }
* @描述 订单延迟队列延时队列
* @author Earl
* @version 1.0.0
* @创建日期 2024/11/26
* @since 1.0.0
*/
public Queue orderDelayQueue(){
Map<String, Object> arguments = new HashMap<>();
arguments.put("x-dead-letter-exchange","order-event-exchange");
arguments.put("x-dead-letter-routing-key","order.release.order");
arguments.put("x-message-ttl",60000);
return new Queue("order.delay.queue", true, false, false, arguments);
}
/**
* @return {@link Queue }
* @描述 订单延迟队列路由队列
* @author Earl
* @version 1.0.0
* @创建日期 2024/11/26
* @since 1.0.0
*/
public Queue orderReleaseOrderQueue(){
return new Queue("order.release.order.queue",true,false,false);
}
/**
* @return {@link Exchange }
* @描述 订单服务通用主题交换器
* @author Earl
* @version 1.0.0
* @创建日期 2024/11/26
* @since 1.0.0
*/
public Exchange orderEventExchange(){
return new TopicExchange("order-event-exchange",true,false);
}
/**
* @return {@link Binding }
* @描述 延迟队列的延时队列order.delay.queue和订单服务通用交换器order-event-exchange的绑定关系
* @author Earl
* @version 1.0.0
* @创建日期 2024/11/26
* @since 1.0.0
*/
public Binding orderCreateOrderBinding(){
return new Binding("order.delay.queue",
Binding.DestinationType.QUEUE,
"order-event-exchange",
"order.create.order",
null);
}
/**
* @return {@link Binding }
* @描述 延迟队列的路由队列order.release.order.queue和订单服务通用交换器order-event-exchange的绑定关系
* @author Earl
* @version 1.0.0
* @创建日期 2024/11/26
* @since 1.0.0
*/
public Binding orderReleaseOrderBinding(){
return new Binding("order.release.order.queue",
Binding.DestinationType.QUEUE,
"order-event-exchange",
"order.release.order",
null);
}
}
测试延时队列
[消息生产者]
xxxxxxxxxx
public class HelloController{
RabbitTemplate rabbitTemplate;
"/test/createOrder") (
public String createOrderTest(){
//1. 创建订单
OrderEntity entity = new OrderEntity();
entity.setOrderSn(UUID.randomUUID().toString());
entity.setModifyTime(new Date());
//2. 给消息队列发送订单消息
rabbitTemplate.convertAndSend("order-event-exchange","order.create.order",entity);
return "ok";
}
}
[消息消费者]
注意因为此前我们在订单服务的配置文件中使用了配置spring.rabbitmq.listener.simple.acknowledge-mode=manual
开启了消息消费者接收消息手动确认模式,因此这里我们获取到信息以后一定要拿到信道通过信道手动应答
该延时队列的效果是发送消息一分钟后消费者收到消息
xxxxxxxxxx
public class CustomRabbitMQConfig{
queues="order.release.order.queue") (
public void listener(OrderEntity entity,Channel channel,Message message){
System.out.println("收到已成功创建的订单信息,准备检查处理订单状态"+entity.getOrderSn());
channel.basicAck(message.getMessageProperties().getDeliveryTag(),false);
}
}
SpringBoot
抽取了一个高级消息队列协议场景启动器spring-boot-starter-ampq
,只需要引入该场景启动器就能快速使用RabbitMQ相关的内容
我们在订单服务中整合使用spring-boot-starter-ampq
,订单服务有数据库表,是由renren-generator
快速生成的
以下的所有RabbitMQ标题都是SpringBoot整合RabbitMQ期间用到的,这里因为标题最小到第六级标题,以后再调整
引入依赖
xxxxxxxxxx
<!--引入amqp场景启动器使用RabbitMQ-->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-amqp</artifactId>
</dependency>
开启使用RabbitMQ的功能
在配置类上使用注解@EnableRabbit
开启RabbitMQ的相关功能
配置RabbitMQ服务器信息
xxxxxxxxxx
#配置RabbitMQ服务器地址
spring.rabbitmq.host=192.168.56.10
#配置RabbitMQ服务器的AMQP通信协议端口
spring.rabbitmq.port=5672
#配置RabbitMQ服务器的虚拟主机,只有一个虚拟主机就使用默认的/
spring.rabbitmq.virtual-host=/
#用户名和密码如果自己没有设置都会使用默认的guest
自动配置原理
引入amqp
场景启动器自动配置类RabbitAutoConfiguration
会自动生效,该自动配置类会自动给容器注入组件CachingConnectionFactory
、RabbitTemplate
、AmqpAdmin
、RabbitMessagingTemplate
[RabbitAutoConfiguration
]
xxxxxxxxxx
RabbitTemplate.class, Channel.class }) ({
RabbitProperties.class) (
RabbitAnnotationDrivenConfiguration.class) (
public class RabbitAutoConfiguration {
(ConnectionFactory.class)
protected static class RabbitConnectionFactoryCreator {
//这个是给Spring容器中放入RabbitMQ的连接工厂组件来获取与RabbitMQ服务器的连接
public CachingConnectionFactory rabbitConnectionFactory(RabbitProperties properties,
ObjectProvider<ConnectionNameStrategy> connectionNameStrategy) throws Exception {
PropertyMapper map = PropertyMapper.get();
CachingConnectionFactory factory = new CachingConnectionFactory(
getRabbitConnectionFactoryBean(properties).getObject());
map.from(properties::determineAddresses).to(factory::setAddresses);//连接工厂从properties即RabbitProperties中找到所有的连接信息,所有关于RabbitMQ连接信息都在RabbitProperties中封装着
map.from(properties::isPublisherConfirms).to(factory::setPublisherConfirms);
map.from(properties::isPublisherReturns).to(factory::setPublisherReturns);
RabbitProperties.Cache.Channel channel = properties.getCache().getChannel();
map.from(channel::getSize).whenNonNull().to(factory::setChannelCacheSize);
map.from(channel::getCheckoutTimeout).whenNonNull().as(Duration::toMillis)
.to(factory::setChannelCheckoutTimeout);
RabbitProperties.Cache.Connection connection = properties.getCache().getConnection();
map.from(connection::getMode).whenNonNull().to(factory::setCacheMode);
map.from(connection::getSize).whenNonNull().to(factory::setConnectionCacheSize);
map.from(connectionNameStrategy::getIfUnique).whenNonNull().to(factory::setConnectionNameStrategy);
return factory;
}
private RabbitConnectionFactoryBean getRabbitConnectionFactoryBean(RabbitProperties properties)
throws Exception {
PropertyMapper map = PropertyMapper.get();
RabbitConnectionFactoryBean factory = new RabbitConnectionFactoryBean();
map.from(properties::determineHost).whenNonNull().to(factory::setHost);
map.from(properties::determinePort).to(factory::setPort);
map.from(properties::determineUsername).whenNonNull().to(factory::setUsername);
map.from(properties::determinePassword).whenNonNull().to(factory::setPassword);
map.from(properties::determineVirtualHost).whenNonNull().to(factory::setVirtualHost);
map.from(properties::getRequestedHeartbeat).whenNonNull().asInt(Duration::getSeconds)
.to(factory::setRequestedHeartbeat);
RabbitProperties.Ssl ssl = properties.getSsl();
if (ssl.isEnabled()) {
factory.setUseSSL(true);
map.from(ssl::getAlgorithm).whenNonNull().to(factory::setSslAlgorithm);
map.from(ssl::getKeyStoreType).to(factory::setKeyStoreType);
map.from(ssl::getKeyStore).to(factory::setKeyStore);
map.from(ssl::getKeyStorePassword).to(factory::setKeyStorePassphrase);
map.from(ssl::getTrustStoreType).to(factory::setTrustStoreType);
map.from(ssl::getTrustStore).to(factory::setTrustStore);
map.from(ssl::getTrustStorePassword).to(factory::setTrustStorePassphrase);
map.from(ssl::isValidateServerCertificate)
.to((validate) -> factory.setSkipServerCertificateValidation(!validate));
map.from(ssl::getVerifyHostname).to(factory::setEnableHostnameVerification);
}
map.from(properties::getConnectionTimeout).whenNonNull().asInt(Duration::toMillis)
.to(factory::setConnectionTimeout);
factory.afterPropertiesSet();
return factory;
}
}
(RabbitConnectionFactoryCreator.class)
protected static class RabbitTemplateConfiguration {
private final RabbitProperties properties;
private final ObjectProvider<MessageConverter> messageConverter;
private final ObjectProvider<RabbitRetryTemplateCustomizer> retryTemplateCustomizers;
public RabbitTemplateConfiguration(RabbitProperties properties,
ObjectProvider<MessageConverter> messageConverter,
ObjectProvider<RabbitRetryTemplateCustomizer> retryTemplateCustomizers) {
this.properties = properties;
this.messageConverter = messageConverter;
this.retryTemplateCustomizers = retryTemplateCustomizers;
}
//给Spring容器中放入RabbitTemplate组件
(ConnectionFactory.class)
public RabbitTemplate rabbitTemplate(ConnectionFactory connectionFactory) {
PropertyMapper map = PropertyMapper.get();
RabbitTemplate template = new RabbitTemplate(connectionFactory);
MessageConverter messageConverter = this.messageConverter.getIfUnique();//这个MessageConverter就是给消息对象做序列化的,会优先使用容器中获取MessageConverter类型的组件作为MessageConverter,如果容器中没有就会默认自己实例化一个SimpleMessageConverter对象来做序列化,消息转换器SimpleMessageConverter会对消息进行判断,如果消息对象是String类型,直接获取String类型的bytes数组,如果不是String类型且实现了Serializable接口就使用序列化工具SerializationUtils的serialize(object)将对象转换成bytes数组,即实际上序列化是MessageConverter在起作用
if (messageConverter != null) {
template.setMessageConverter(messageConverter);
}
template.setMandatory(determineMandatoryFlag());
RabbitProperties.Template properties = this.properties.getTemplate();
if (properties.getRetry().isEnabled()) {
template.setRetryTemplate(new RetryTemplateFactory(
this.retryTemplateCustomizers.orderedStream().collect(Collectors.toList())).createRetryTemplate(
properties.getRetry(), RabbitRetryTemplateCustomizer.Target.SENDER));
}
map.from(properties::getReceiveTimeout).whenNonNull().as(Duration::toMillis)
.to(template::setReceiveTimeout);
map.from(properties::getReplyTimeout).whenNonNull().as(Duration::toMillis).to(template::setReplyTimeout);
map.from(properties::getExchange).to(template::setExchange);
map.from(properties::getRoutingKey).to(template::setRoutingKey);
map.from(properties::getDefaultReceiveQueue).whenNonNull().to(template::setDefaultReceiveQueue);
return template;
}
private boolean determineMandatoryFlag() {
Boolean mandatory = this.properties.getTemplate().getMandatory();
return (mandatory != null) ? mandatory : this.properties.isPublisherReturns();
}
//给容器中添加AmqpAdmin组件
(ConnectionFactory.class)
(prefix = "spring.rabbitmq", name = "dynamic", matchIfMissing = true)
public AmqpAdmin amqpAdmin(ConnectionFactory connectionFactory) {
return new RabbitAdmin(connectionFactory);
}
}
(RabbitMessagingTemplate.class)
(RabbitMessagingTemplate.class)
(RabbitTemplateConfiguration.class)
protected static class MessagingTemplateConfiguration {
//给容器中注入一个RabbitMessagingTemplate
(RabbitTemplate.class)
public RabbitMessagingTemplate rabbitMessagingTemplate(RabbitTemplate rabbitTemplate) {
return new RabbitMessagingTemplate(rabbitTemplate);
}
}
}
[RabbitProperties
]
所有关于RabbitMQ的配置都以spring.rabbitmq
作为前缀
xxxxxxxxxx
prefix = "spring.rabbitmq") (
public class RabbitProperties {
/**
* RabbitMQ host.
*/
private String host = "localhost";
/**
* RabbitMQ port.
*/
private int port = 5672;
/**
* Login user to authenticate to the broker.
*/
private String username = "guest";
/**
* Login to authenticate against the broker.
*/
private String password = "guest";
/**
* SSL configuration.
*/
private final Ssl ssl = new Ssl();
/**
* Virtual host to use when connecting to the broker.
*/
private String virtualHost;
/**
* Comma-separated list of addresses to which the client should connect.
*/
private String addresses;
/**
* Requested heartbeat timeout; zero for none. If a duration suffix is not specified,
* seconds will be used.
*/
(ChronoUnit.SECONDS)
private Duration requestedHeartbeat;
/**
* Whether to enable publisher confirms.
*/
private boolean publisherConfirms;
/**
* Whether to enable publisher returns.
*/
private boolean publisherReturns;
/**
* Connection timeout. Set it to zero to wait forever.
*/
private Duration connectionTimeout;
/**
* Cache configuration.
*/
private final Cache cache = new Cache();
/**
* Listener container configuration.
*/
private final Listener listener = new Listener();
private final Template template = new Template();
private List<Address> parsedAddresses;
...
}
该对象可以帮助我们创建、销毁交换器、队列、绑定关系,简言之所有WEB管理客户端能做的操作都可以通过该对象实现,即使用Java代码创建删除交换器、队列并且为两者创建绑定关系
除了使用AmqpAdmin
来创建队列、交换器、绑定关系,SpringBoot
还自动实现了当容器中使用@Bean
注解注入队列、交换器、绑定关系组件如果RabbitMQ服务中没有就会自动创建,用户只需要向容器中注入对应的组件,无需再通过AmqpAdmin
来创建队列、交换器或者绑定关系
但是特别注意:通过将队列、交换器、绑定关系注入容器通过SpringBoot
自动在RabbitMQ
服务器中创建的队列、交换器和绑定,一旦执行过一次在RabbitMQ
服务器中有了同名的队列、交换器或者绑定关系,这些组件即使在SpringBoot
中的配置发生了变化,比如更改了持久化策略、消息有效时间等配置参数,在系统重启初始化组件的时候不会对RabbitMQ
中的同名队列、交换器或者绑定关系进行修改,RabbitMQ
中的对应组件仍然会维持第一次创建时的配置,除非我们将RabbitMQ
中的这些已经存在的同名队列、交换器、绑定关系手动删除再启动SpringBoot
项目,系统才会重新在RabbitMQ
服务器中创建更改了配置的队列、交换器和绑定关系,注意SpringBoot
在RabbitMQ
中创建容器组件对应的队列、交换器和绑定关系的时机是SpringBoot
第一次连接开启消息监听的时候,注意只要有监听队列就会检查创建所有的容器组件中的队列、交换器和绑定关系,没有发送消息和接收消息只要绑定了监听队列就会将容器组件中所有的队列、交换器和绑定关系全部检查并创建出来
注意RabbitMQ中手动删除队列、队列相关的绑定关系也会自动删除
void ---> amqpAdmin.declareExchange(Exchange exchange)
功能解析:在RabbitMQ服务器中创建一个交换器
使用示例:
xxxxxxxxxx
SpringRunner.class) (
public class MallOrderApplicationTests {
AmqpAdmin amqpAdmin;
public void createExchange() {
DirectExchange directExchange = new DirectExchange("mall-direct-exchange", true, false);
amqpAdmin.declareExchange(directExchange);
log.info("Exchange[{}]创建成功","mall-direct-exchange");
}
}
示例含义:在RabbitMQ服务器中创建一个名为mall-direct-exchange
的直接交换器
补充说明:
交换器Exchange是一个接口,有一个抽象子类AbstractExchange
,该抽象子类有五个子实现类,分别为下列所示,通过这五个子实现类来创建对应类型的交换器
DirectExchange
:直接交换器
全参构造为public DirectExchange(String name, boolean durable, boolean autoDelete, Map<String, Object> arguments)
,分别表示交换器的名字、交换器是否设置为持久化、交换器是否自动删除以及为交换器指定键值对形式的参数,如果无需指定参数可以使用不带该参数的重载构造方法,默认也是创建的持久化交换器和非自动删除的交换器
HeadersExchange
:Headers交换器
FanoutExchange
:扇出交换器
TopicExchange
:主题交换器
CustomExchange
:自定义交换器
void ---> amqpAdmin.declareQueue(Queue queue)
功能解析:在RabbitMQ服务器中创建一个队列
使用示例:
xxxxxxxxxx
SpringRunner.class) (
public class MallOrderApplicationTests {
AmqpAdmin amqpAdmin;
public void createQueue() {
Queue queue = new Queue("mall-hello-queue",true,false,false);
amqpAdmin.declareQueue(queue);
log.info("Queue[{}]创建成功","mall-hello-queue");
}
}
示例含义:在RabbitMQ服务器中创建一个名为mall-hello-queue
的队列
补充说明:
队列Queue只是一个类,不是接口也没有子类,我们直接通过实例化Queue对象就能声明一个队列,注意这个Queue不是java.util
包下的,是org.springframework.amqp.core
包下的
队列全参构造public Queue(String name, boolean durable, boolean exclusive, boolean autoDelete, Map<String, Object> arguments)
,参数分别为队列的名字、是否持久化、是否排他[排他是指该队列只能被一条连接独占,只要有一条连接连上了该队列,其他连接都连不上该队列,实际开发队列不应该是排他的,我们更希望多个客户端来连接同一条队列,只是最终只有一个客户端获取到消息],是否自动删除,为队列配置一些参数,如果不需要指定参数可以使用不带该参数的重载构造方法,注意这里的参数是队列的相关配置,参数示例列举如下
队列中消息的最大存活时间,
队列的死信交换器,
死信消息的路由键等
void ---> amqpAdmin.declareBinding(Binding binding)
功能解析:在RabbitMQ服务器中创建一个绑定关系
使用示例:
xxxxxxxxxx
SpringRunner.class) (
public class MallOrderApplicationTests {
AmqpAdmin amqpAdmin;
public void createBinding() {
Binding binding = new Binding("mall-hello-queue",
Binding.DestinationType.QUEUE,
"mall-direct-exchange",
"hello rabbitmq",null);
amqpAdmin.declareBinding(binding);
log.info("Binding[{}]创建成功","mall-hello-binding");
}
}
示例含义:在交换器mall-direct-exchange
和队列mall-hello-queue
之间创建一个绑定关系
补充说明:
Binding也是一个类,没有子类
Binding的全参构造public Binding(String destination, DestinationType destinationType, String exchange, String routingKey,Map<String, Object> arguments)
参数destination
是目的地名字[这个目的地可以是队列名字也可以是交换器名字]
DestinationType
是目的地类型[目的地类型可以是交换器也可以是队列,这个DestinationType
是一个枚举]
exchange
是我们要进行绑定的交换器名字
RountingKey
就是绑定关系中对应的Binding key
要匹配消息的RoutingKey
注意Binding
的构造不传参自定义参数必须要指定为null
,没有对应不含该参数的构造
该对象可以帮助我们向RabbitMQ服务器中发送消息,也可以从RabbitMQ服务器中获取消息
void ---> rabbitTemplate.convertAndSend(String exchange,String routingKey,Object object)
功能解析:该方法将我们传入的object对象转换成字节流数据发送给RabbitMQ服务器中指定的交换器
使用示例:
xxxxxxxxxx
topic = "test.rabbitmq") (
SpringRunner.class) (
public class MallOrderApplicationTests {
RabbitTemplate rabbitTemplate;
public void sendMessage(){
OrderReturnReasonEntity returnReason = new OrderReturnReasonEntity();
returnReason.setId(1L);
returnReason.setCreateTime(new Date());
returnReason.setName("guest");
rabbitTemplate.convertAndSend("mall-direct-exchange","hello.rabbitmq",returnReason);
log.info("消息[{}]发送成功",returnReason);
}
}
示例含义:在RabbitMQ服务器中创建一个名为mall-direct-exchange
的直接交换器
补充说明:
rabbitTemplate
有原生的send
方法也可以发送消息,但是该方法需要传参被封装成Message类型的消息
参数exchange
是交换机的名字,参数rounting key
是消息的路由键,参数object
是消息本身
RabbitMQ队列中存储的消息的内容是被编码过的,默认的消息类型是application/x-java-serialized-object
即默认是使用的Java序列化器来进行的编码,这要求作为消息发送的对象对应的类必须实现了序列化接口Serializable
序列化实际上是amqp
包下的MessageConverter
在起作用,MessageConverter
是一个接口,在抽象类AbstractMessageConverter
中有一个子实现类AbstractJackson2JsonMessageConverter
[注意Jackson2Json
意思是通过Jackson
转成json
,2是to的谐音],注意AbstractMessageConverter
中还有一个子实现类WhiteListDeserializingMessageConverter
,默认配置的SimpleMessageConverter
是WhiteListDeserializingMessageConverter
的一个子类,要自定义序列化机制就要给容器注入一个MessageConverter
组件,我们想将消息序列化成一个json
对象就可以通过向容器注入一个AbstractJackson2JsonMessageConverter
来实现
这个感觉设计的很糟糕,该方法不返回任何值,发送消息失败比如没有创建绑定关系消息无法入队列不会报错没有返回值也不会抛异常,出了问题都不知道
默认的序列化使用的是Java的序列化,需要消息对象实现了Serializable接口,这种序列化通过web管理控制台看起来不直观,也不跨语言平台,我们一般希望将消息对象序列化为json对象,这样能实现跨语言平台通信
原理
RabbitTemplate中的messageConverter就是给消息对象做序列化的,在自动配置类RabbitAutoConfiguration
中会优先从容器中获取MessageConverter类型的组件作为RabbitTemplate的MessageConverter,
如果容器中没有就会默认自己实例化一个SimpleMessageConverter对象来做序列化,消息转换器SimpleMessageConverter会对消息进行判断,如果消息对象是String类型,直接获取String类型的bytes数组,如果不是String类型且实现了Serializable接口就使用序列化工具SerializationUtils的serialize(object)将对象转换成bytes数组,即序列化实际上是amqp
包下的MessageConverter
在起作用,
MessageConverter
是一个接口,在抽象类AbstractMessageConverter
中有一个子实现类AbstractJackson2JsonMessageConverter
[注意Jackson2Json
意思是通过Jackson
转成json
,2是to的谐音],注意AbstractMessageConverter
中还有一个子实现类WhiteListDeserializingMessageConverter
,默认配置的SimpleMessageConverter
是WhiteListDeserializingMessageConverter
的一个子类,
要自定义序列化机制就要给容器注入一个MessageConverter
组件,我们想将消息序列化成一个json
对象就可以通过向容器注入一个AbstractJackson2JsonMessageConverter
来实现
配置步骤
向容器中注入AbstractJackson2JsonMessageConverter
来替代默认的SimpleMessageConverter
来将消息对象序列化成json
对象
注意使用AbstractJackson2JsonMessageConverter
,在web管理端界面我们获取到消息后能观察到消息的content_type
由原来的application/x-java-serialized-object
变成了application/json
而且在消息头中还有一个_TypeId_
字段,记录者消息的全限定类名
xxxxxxxxxx
/**
* @author Earl
* @version 1.0.0
* @描述 RabbitMQ客户端自定义配置
* @创建日期 2024/11/01
* @since 1.0.0
*/
public class MallRabbitConfig {
/**
* @return {@link MessageConverter }
* @描述 给容器中注入一个使用Jackson将消息对象序列化为json对象的消息转换器
* @author Earl
* @version 1.0.0
* @创建日期 2024/11/01
* @since 1.0.0
*/
public MessageConverter messageConverter(){
return new Jackson2JsonMessageConverter();
}
}
RabbitListener
意思是RabbitMQ消息的监听器,作用是监听队列,该注解的属性queues是一个String类型的数组,可以指定要监听的必须存在的一个或者多个队列,只要队列中有消息,我们就可以获取到该消息
注意该注解必须在主启动类上使用了注解@EnableRabbit
开启了RabbitMQ的相关功能才能正常使用,因为该注解的使用必须有@EnableRabbit
注解相关的功能支持,而且目标队列必须存在
实际上只是想给RabbitMQ创建交换器、队列、绑定关系、发消息可以不标注@EnableRabbit
注解,但是一旦想要监听队列中的消息就必须在配置类上添加@EnableRabbit
注解
该注解必须标注在容器组件的方法上才能起作用[验证一下是否必须标注在@Service
注解标注的类上],经过验证只要是组件就行,只要队列一有消息就会自动接收到消息并自动封装到参数列表中名为message
的Object
类型的参数中
监听消息并获取消息头和消息体
Body就是消息本身
messageProperties是消息属性,就是消息头中的属性值[即消息类型ID、消息内容类型等等]
这里我们用Object接受消息,实际上message的真正类型是org.springframework.amqp.core.Message
,因此我们直接将Object类型改成Message类型
我们可以通过byte[] body=message.getBody()
获取消息体的内容,通过MessageProperties properties=message.getMessageProperties()
获取消息头的属性
这种方式获取的消息体实际上是一个字节数组,我们要将其转换成指定对象需要使用FastJson这样的将JSON对象转换成实体类的解析工具
xxxxxxxxxx
queues = {"mall-hello-queue"}) (
public void getMessage(Object message){
System.out.println(message);//(Body:'{"id":1,"name":"guest","sort":null,"status":null,"createTime":1730433030106}' MessageProperties [headers={__TypeId__=com.earl.mall.order.entity.OrderReturnReasonEntity}, contentType=application/json, contentEncoding=UTF-8, contentLength=0, receivedDeliveryMode=PERSISTENT, priority=0, redelivered=false, receivedExchange=mall-direct-exchange, receivedRoutingKey=hello.rabbitmq, deliveryTag=1, consumerTag=amq.ctag-JWa106mr53fwD8CC1wmpmQ, consumerQueue=mall-hello-queue])
System.out.println(message.getBody());//[B@6917b84f
System.out.println(message.getMessageProperties());
}
监听消息并将消息体自动转换成消息对应类型
我们还可以参数列表通过指定消息的实际类型,让Spring
自动将消息体转换为对应的类型,因为消息头中保存了消息体的全限定类名,但是这个转换方法很有意思,应该不是使用的fastjson,我们对fastjson很熟悉,但是对这个转换方法不太清楚
xxxxxxxxxx
queues = {"mall-hello-queue"}) (
public void getMessage(Message message, OrderReturnReasonEntity messageContent){
byte[] body = message.getBody();
MessageProperties messageProperties = message.getMessageProperties();
System.out.println(messageContent);
//OrderReturnReasonEntity(id=1, name=guest, sort=null, status=null, createTime=Fri Nov 01 12:11:12 CST 2024)
}
监听消息并获取信道
获取当前传输数据的信道,每个客户端只会与RabbitMQ建立一个连接,但是可以在一个连接内创建多个信道,该信道一般用在可靠性投递场景中
xxxxxxxxxx
queues = {"mall-hello-queue"}) (
public void getMessage(Message message,
OrderReturnReasonEntity messageContent,
Channel channel){
byte[] body = message.getBody();
MessageProperties messageProperties = message.getMessageProperties();
System.out.println(messageContent);
//OrderReturnReasonEntity(id=1, name=guest, sort=null, status=null, createTime=Fri Nov 01 12:11:12 CST 2024)
}
注意
一个队列可以被很多个客户端监听,但是最后只有一个客户端能收到消息,只要有一个客户端收到消息队列就会删除消息,而且还会保证只能有一个客户端成功获取该消息
如果上述代码因为多个服务实例以上代码同时在三个服务实例中生效三个服务实例中的上述代码同时监听一个队列中的消息,最后也只会有一个服务实例成功获取到消息,弹幕说这个过程还可以应用负载均衡策略
卧槽,单元测试相当于新开一个服务实例,如果我们使用单元测试发送消息,消息发送出去单元测试的服务实例还没来得及销毁也会在期间监听并获取到消息
一个服务实例的一个监听消息方法获取消息的过程是加了锁的,只有当前服务实例获取到消息并完成执行完被标注方法才会释放锁,当前服务实例才能获取监听队列中的下一个消息并加锁执行被标注方法,即只有获取到一个消息并将被标注方法执行完,当前方法才能继续获取下一个消息
@RabbitListener
除了标注在方法上还可以被标注在类上,但是@RabbitHandler
只能标注在方法上
实际开发中一般@RabbitListener
和@RabbitHandler
一起使用,将@RabbitListener
标注在类上依赖指明要监听的所有队列,将@RabbitHandler
标注在方法上用来指明消息将要执行的方法,通过参数列表的参数封装类型和消息头中的全限定类名来匹配为同一个队列的不同封装类型的消息执行不同的指定业务方法,也可以实现不同的队列去执行各自消息封装类型作为参数的方法[实际我感觉这么用是多此一举,我完全可以在多个方法上都标注@RabbitListener
注解嘛,这里在开发中再细细体会],不过@RabbitHandler
标注在自定义重载方法上只是区分不同的消息封装类型处理方法倒也有点意思
注意这里有个坑!必须给消息对象的封装类型提供一个无参构造器!否则会报错!
RabbitMQ的消息确认是为了保证消息的可靠抵达,分布式集群系统中,多个微服务连接RabbitMQ收发消息,可能会出现由于网络闪断、运行实例和RabbitMQ服务器宕机都可能导致消息丢失[比如生产者发送消息由于网络波动RabbitMQ没收到消息、或者RabbitMQ收到消息但是消费者由于网络波动没有收到消息];因此在一些关键消息环节,我们都需要使用合适的方法来保证消息不会丢失,比如订单消息引起的对库存、积分、优惠计算、物流等等,这些消息千万不能丢,一丢就会导致经济纠纷,不论是生产者发送消息可靠抵达RabbitMQ,也不论是消费者接收消息都要保证消息的可靠抵达,如果出现了错误我们也必须要能知道哪些消息丢失
方案一:使用事务消息,我们可以设置连接中的信道是事务模式,只有消息从发出到消费者收到消息完整响应以后消息的发送才算成功,但是事务消息会导致性能的严重下降,官方文档描述性能会下降250倍,在RabbitMQ-v3-12
官方文档-英文的服务端文档下的Reliable Delivery可靠投递的Acknowledgements and Confirms的Publisher confirms发送者确认中提到使用标准AMQP协议,可以使用事务来保证消息不会丢失,事务的作用是让整个信道变成事务化的信道,每个消息的发布提交都是完整的事务,这是没必要的过重消耗,会导致吞吐量降低250倍,为了解决这种事务化通道导致的性能骤降,在AMQP协议中已有的ACK应答机制上发展出来发布者确认回调机制
方案二:为了在分布式集群高并发系统下能快速确认哪些消息成功发送,哪些消息发送失败,我们引入了消息确认机制,在RabbitMQ-v3-12
官方文档-英文的服务端文档下的Reliable Delivery可靠投递,官方文档介绍可靠投递的作用是确保消息即使出现了任何错误信息我们也能感知到并保证消息总是被成功送达,可靠投递要同时保证生产者的消息可靠达到RabbitMQ服务器,RabbitMQ服务器发出的消息要可靠达到消费者,我们可以使用消息确认机制来高性能保证消息的可靠抵达
可靠性投递的消息投递流程
生产者发送消息给RabbitMQ服务器,RabbitMQ服务器收到消息后将消息交给交换器,交换器根据投递策略将消息传递给各个队列,这就是整个发送消息过程,在发送消息过程我们有两个发送者的确认回调[在消息投递的不同时机触发的回调函数]来保证消息的可靠发送
如果生产者的消息成功到达Broker就会触发生产者的确认回调confirmCallback
方法
到达Broker的消息在交换器投递给队列的过程中也可能出现投递失败的情况,当消息被交换器没有成功投递到队列中时会触发第二个生产者的确认回调returnCallback
方法,如果成功投递给队列就不会触发该回调方法
被消费者监听的队列,只要队列收到消息后就会向消费者发送消息,从队列发出消息到消费者成功获取到消息这个过程就是消息接收过程,在消息接收过程我们有一个ack机制[acknowledge,就是消息确认应答机制]来保证接收消息的可靠抵达
ack机制能保证RabbitMQ服务器知道哪些消息都被消费者正确地接收到,如果消费者正确接收到消息,队列就会将对应的消息从队列中删除,如果消费者没有正确接收到消息,队列可能会采用将消息重新投递等兜底措施
confirmCallback
开启生产者确认回调:通过创建connectionFactory
时设置PublisherConfirms(true)
来设置开启confirmCallback
回调,我们可以通过在配置文件中配置spring.rabbitmq.publisher-confirms=true
来实现该功能,该配置项默认是false
消息只要被Broker接收到就会执行confirmCallback
方法,如果是cluster
即RabbitMQ集群模式,需要所有的Broker
都接收到才会调用生产者的confirmCallback
方法,这个回调类似于Ajax的回调是成功后自动回调回来的,即使当前系统没有任何消费者监听任何队列只要消息发出被RabbitMQ服务器成功接收就会触发该回调并执行回调对象confirmCallback
的confirm
方法,但是这同时意味着所有消息的发送确认回调执行方法都是一样的
该回调只是保证消息成功到达RabbitMQ
服务器,并不能保证消息一定会被成功投递到目标队列也不能保证消息能被成功投递到消费者
注意ConfirmCallback
实际上是RabbitTemplate
中的一个接口,该接口中有一个confirm方法,当消息被RabbitMQ服务器成功接收就会执行用户自定义的回调方法confirm
,该方法的参数列表中的correlationData
是每个消息的唯一标识,ack
表示消息是否被RabbitMQ服务器正确收到[true
表示收到,false
表示未被收到],cause
表示消息没有被正常收到RabbitMQ服务器返回的原因;
rabbitTemplate
中有一个非空私有属性confirmCallback
就是该接口的实例化对象,我们只要使用注解@PostConstruct
在IoC
容器初始化时将自定义的ConfirmCallback
匿名实现实例化对象并调用rabbitTemplate
的setConfirmCallback(ConfirmCallback confirmCallback)
方法来将自定义的消息可靠发送确认回调设置到rabbitTemplate
的属性confirmCallback
中
@PostConstruct
注解标注的方法在该注解所在类对应的组件对象被实例化以后立即执行被该注解标注的方法
correlationData
:用来表示当前消息的唯一性,CorrelationData
是一个类,里面用来标识唯一性的主要就是其中的id
属性,我们发送消息时可以指定消息的唯一id
,一般都是使用UUID
,发送消息时我们可以调用rabbitTemplate.convertAndSend("mall-direct-exchange","hello.rabbitmq",returnReason);
的重载方法void ---> rabbitTemplate.convertAndSend(String exchange,String rountingKey,Object message,CorrelationData correlationData)
发送消息通过第四个参数指定消息的唯一标识,该标识将会被封装到发送端消息抵达确认的回调中作为消息的唯一标识,CorrelationData
的单参构造就是封装其中的String
类型的id
属性,我们一般直接传参UUID
,如果发送消息时没有指定CorrelationData
回调时的参数CorrelationData
就是null
,实际开发中的用法一般是以上一步处理保存的数据与消息的关联关系的唯一标识作为消息的唯一标识,一旦发送消息过程消息丢失可以根据该标识再次组织消息重新发送或者定时扫描数据库哪些消息没有成功到达消息队列再重新发送
保证消息可靠性的方式之一,数据库日志记录,通过状态判断,投递失败的消息通过定时任务重新发布
开启步骤
1️⃣:配置配置项spring.rabbitmq.publisher-confirms=true
开启发送端消息抵达RabbitMQ服务器确认
2️⃣:为rabbitTemplate
在容器初始化时配置我们自定义的ConfirmCallback
实例化对象
xxxxxxxxxx
/**
* @author Earl
* @version 1.0.0
* @描述 RabbitMQ客户端自定义配置
* @创建日期 2024/11/01
* @since 1.0.0
*/
public class MallRabbitConfig {
private RabbitTemplate rabbitTemplate;
public void initRabbitTemplate(){
/*
1. 设置RabbitMQ服务器收到消息后的确认回调ConfirmCallback
配置配置项spring.rabbitmq.publisher-confirms=true
为rabbitTemplate设置回调实例化对象confirmCallback
*/
rabbitTemplate.setConfirmCallback(new RabbitTemplate.ConfirmCallback() {
/**
* @param correlationData 当前消息的唯一关联标识,里面的id就是消息标识的唯一id
* @param ack 消息是否成功收到
* @param cause 消息发送失败的原因
* @描述 1. 只要消息抵达Broker就会触发该回调,与消费者和消息是否入队列无关
* @author Earl
* @version 1.0.0
* @创建日期 2024/11/02
* @since 1.0.0
*/
public void confirm(CorrelationData correlationData, boolean ack, String cause) {
log.info("message confirm: {correlationData:"+correlationData+
"ack:"+ack+
"cause:"+cause+"}");
}
});
}
}
returnCallback
消息正确抵达RabbitMQ
中的队列就会触发该回调,如果消息的路由键写错了无法匹配到队列、队列被删了、RabbitMQ
集群是镜像集群,每个从节点的数据都是从主节点复制同步过来的,消息正常投递要求集群中的每个节点都得投递成功才行,只要有一个节点投递不成功投递也是失败的
开启步骤
1️⃣:配置配置项spring.rabbitmq.publisher-returns=true
开启发送端消息抵达队列确认,注意是没有成功抵达队列才会触发该回调
2️⃣:配置配置项spring.rabbitmq.template.mandatory=true
,该配置项的意思是只要消息没有成功抵达队列,以异步发送的方式优先回调returnConfirm
,突出一个异步
3️⃣:为rabbitTemplate
在容器初始化时配置我们自定义的ReturnConfirm
实例化对象
xxxxxxxxxx
/**
* @author Earl
* @version 1.0.0
* @描述 RabbitMQ客户端自定义配置
* @创建日期 2024/11/01
* @since 1.0.0
*/
public class MallRabbitConfig {
private RabbitTemplate rabbitTemplate;
public void initRabbitTemplate(){
/*
2.设置RabbitMQ队列没有收到消息的确认回调ReturnCallback
配置配置项spring.rabbitmq.publisher-returns=true
配置配置项spring.rabbitmq.template.mandatory=true
为rabbitTemplate设置回调实例化对象returnCallback
* */
rabbitTemplate.setReturnCallback(new RabbitTemplate.ReturnCallback() {
/**
* @param message 投递失败的消息本身的详细信息
* @param replyCode 导致消息投递失败的错误状态码
* @param replyText 导致消息投递失败的错误原因
* @param exchange 当时该消息发往的具体交换器
* @param routingKey 当时该消息的具体路由键
* @描述
* @author Earl
* @version 1.0.0
* @创建日期 2024/11/03
* @since 1.0.0
*/
public void returnedMessage(Message message, int replyCode, String replyText, String exchange, String routingKey) {
log.info("message lose: {message:"+message+
"replyCode:"+replyCode+
"replyText:"+replyText+
"exchange:"+exchange+
"routingKey"+routingKey);
}
});
}
}
returnCallback
消费者消息可靠抵达的Ack消息确认机制,该机制的原理是一旦消费者从队列中获取到消息就会自动给RabbitMQ回复确认收到消息,该机制是AMQP协议中的机制,默认该机制就是开启的,一旦消费者收到消息就会自动给RabbitMQ服务器回复确认,RabbitMQ服务器收到确认队列中的消息就会被移除
❓:注意这种自动应答Ack的消息确认机制存在很严重的问题,假如队列中有5条消息,我们通过打断点的方式只处理一条消息,注意这里老师的意思是消息接收和处理是独立过程,消息是一次性被消费者接收并且只要接收到就自动回复了[这里我不太理解,之前讲消息监听和接收时讲过一个服务实例只有在完整执行完标注了注解就只能解释为只要有多个服务实例,RabbitMQ会自动决定哪些消息去往那些消费者,不会根据消息者的实际处理情况来判断下一条消息去往那个消费者。除非消息没有成功到达消费者才会重新把消息入队列再次将消息发送给其中一个消费者,感觉这也很合理,比如限制接口的QPS,频率太高的请求会自动被拒绝处理,根本到不了接收消息的那一步,也就不会有Ack应答。而对消息的处理的串行化实际上是服务内部对处理消息的方法单独上锁,这个锁并不影响消息的接收],这里把消息的负载均衡看做是RabbitMQ服务器的内部决策,消息是只要服务器有接收能力就直接发送给服务器,不会等到上一条消息被处理完再发送下一条消息,消息处理的串行化是消费者运行实例内部的处理,只要消息一到达消费者,不管消息是否被处理,都会立即Ack应答RabbitMQ服务器,RabbitMQ会直接将队列中的消息直接删除,但是消费者对同一条队列的消息是串行化处理的,一旦消费者出现问题比如宕机、该服务实例无法处理该消息等原因,还没有被处理的消息就会因为没来得及处理直接丢失,此时消息队列无法收到消息还没有被处理的通知,队列中的消息也已经删了,就算收到了通知也无济于事@RabbitListener
或者@RabbitHandler
的方法以后才会接收处理下一个消息,试验也证明当前消息处理期间下一个消息不会被处理,多个服务实例消息会自动被负载均衡到其他服务实例,
❓:弹幕指出打断点然后停止服务,断点后的代码还是会执行,需要使用taskkill
指令杀死进程,同时指出使用taskkill
指令消息不会被自动确认,仍然留在消息队列中[这个确认时机还需要进一步明确]
🔑:这里老师后面也发生了,确实手动停止服务,断点后面的代码还是会执行完,IDEA会把进程做完才关掉进程,因此这里服务器宕机会不会导致消息丢失还需要等明确自动Ack的应答时机才能确认
🔑:因为自动确认只要消息接收到了就会自动Ack应答即消息还没处理就应答,一旦消息处理过程中出现了服务实例自身没法解决的问题消息就会丢失[比如服务器宕机,当前服务由于外部问题无法根本无法处理某个消息],我们的解决办法是关闭Ack自动应答,采用Ack手动应答的方式在当前服务实例成功处理了某个消息在发起Ack成功应答删除队列中的消息;如果处理失败我们就Ack应答失败让队列重新投递消息或者直接丢弃消息
配置配置项关闭Ack应答的自动确认,开启Ack应答的手动确认
注意只是配置该配置项没有设置确认方法一条消息都不会确认,如果服务器此时宕机连接断开,这些处于Unacked
状态的消息会重新进入Ready
状态,即手动确认是只要我们没有明确告诉RabbitMQ消息已经被签收,这些消息会一直处于Unacked
状态,只要消费者和队列的连接断开,这些消息就会重新入队列,重新变成Ready状态
xxxxxxxxxx
#手动ack消息
spring.rabbitmq.listener.simple.acknowledge-mode=manual
手动签收消息channel.basicAck(long deliveryTag,boolean multiple)
方法
deliveryTag
是当前消息的派发标签,是一个long
类型的数字,这个数字从消息头MessageProperties
的deliveryTag
属性中,即message.getMessageProperties().getDeliveryTag()
获取的,这个属性值最大的特点是在当前信道内是自增的,用来标识一条信道内传输的的消息
multiple
表示设置当前应答是批量应答还是只应答当前消息,为true
表示批量应答[会一次性应答deliveryTag
小于等于当前消息的所有消息],为false
表示只应答当前消息
该方法可能抛出异常,发生异常的原因是网络连接中断了
一般这个方法都在对消息的监听处理方法中通过参数列表获取channel
通过channel
进行调用
手动拒绝签收消息channel.basicNack(long deliveryTag,boolean multiple,boolean requeue)
deliveryTag
当前消息的派发标签,是Channel中消息的唯一凭证
multiple
是否批量应答,true会应答当前消息以前的所有消息
requeue
参数的意思是当前消息是否重新入队,即消息被手动拒收以后消息是否重新发回RabbitMQ,让RabbitMQ重新放到队列中,如果该参数设置为true
即消息重新入队列,如果该参数设置为false
消息会被直接丢弃,直接丢弃相当于Ack拒收队列中的消息也会直接删除
没有调用签收或者拒绝签收方法的消息会一直处于UnAcked
状态,如果此时感知到与消费者连接中断,不管消费者将要对消息采取的拒绝策略是直接丢弃还是重新入队列,都是直接重新入队列
手动拒绝签收消息channel.basicReject(long deliveryTag,boolean requeue)
这个方法和channel.basicNack(long deliveryTag,boolean multiple,boolean requeue)
的效果是一样的,只是上面的方法可以选择是否批量拒绝,这个不能选择
SpringCloud Alibaba需要和SpringCloud、SpringBoot三者相互进行版本配合,版本要求在SpringCloud的官方文档可以查看
SpringBoot微服务引入SpringCloud Alibaba依赖的版本控制
公共服务或者父工程做依赖版本控制【依赖管理】
使用
SpringCloud Alibaba
组件会自动仲裁组件【nacos、Seata、Sentinel、RocketMQ】依赖为2.1.0.RELEASE版本
xxxxxxxxxx
<dependencyManagement>
<dependencies>
<dependency>
<groupId>com.alibaba.cloud</groupId>
<artifactId>spring-cloud-alibaba-dependencies</artifactId>
<version>2.1.0.RELEASE</version>
<type>pom</type>
<scope>import</scope>
</dependency>
</dependencies>
</dependencyManagement>
引入nacos服务注册发现的依赖
xxxxxxxxxx
<!--nacos-->
<dependency>
<groupId>com.alibaba.cloud</groupId>
<artifactId>spring-cloud-alibaba-nacos-discovery</artifactId>
</dependency>
启动nacos服务器nacos-server,通过8848端口可以访问nacos,如http://localhost:8848/nacos
默认账户和密码都是nacos
在每个微服务的配置文件中通过属性Spring.cloud.nacos.discovery.server-addr=localhost:8848
指定nacos-server
的地址,通过属性Spring.application.name=mall_coupon
指定服务的名字,服务名使用横岗和下划线都可以
xxxxxxxxxx
Spring
cloud
nacos
discovery
server-addr localhost8848
application
name mall-coupon
在每个微服务的启动类上使用注解@EnableDiscoveryClient
开启服务注册与发现功能
不太清楚注解
@EnableDiscoveryClient
的作用,因为有案例我没加该注解一样能服务注册和服务发现,没加作为服务调用方一样能成功调用到服务
xxxxxxxxxx
public class MallCouponApplication {
public static void main(String[] args) {
SpringApplication.run(MallCouponApplication.class, args);
}
}
如果没有配置中心,修改配置文件需要每个微服务都要修改重新打包编译测试部署非常麻烦;有了配置中心就能实时同步并让微服务自动更新
以前SpringBoot任何从配置文件中获取属性值的注解都能使用,如@Value、@ConfigurationProperties,而且配置中心有的配置优先于本地配置
引入Nacos Config Starter
xxxxxxxxxx
<!--nacos配置中心-->
<dependency>
<groupId>com.alibaba.cloud</groupId>
<artifactId>spring-cloud-starter-alibaba-nacos-config</artifactId>
</dependency>
在类路径即resources目录下创建bootstrap.properties文件
在该配置文件中配置nacos 配置中心元数据,该配置文件是SpringBoot的文件,会优先于application.properties先加载,从这里面获取配置中心的地址并获取到对应的配置文件
需要配置当前应用的名字并指定配置中心即nacos服务器的地址
高版本SpringBoot使用bootstrap必须加spring-cloud-starter-bootstrap依赖
xxxxxxxxxx
spring.application.name=mall-coupon
spring.cloud.nacos.config.name=.server-addr=localhost:8848
在使用读取配置文件属性值的类上添加注解@RefreshScope注解能同步更新配置文件上的实时更改数据
@RefreshScope注解只加在启动类上是不起作用的,必须添加到使用@Value注解注入对应属性值的类上面,如下所示
如果配置中心和当前应用的本地配置文件中都配置了相同的属性,优先使用配置中心的对应属性配置
xxxxxxxxxx
"coupon/coupon") (
public class CouponController {
private CouponService couponService;
"${coupon.user.name}") (
private String username;
"${coupon.user.age}") (
private int age;
}
配置中心配置命名空间
基于环境做隔离:命名空间的目的就是做环境配置隔离,区分开发、测试、生产环境;默认不在bootstrap中配置属性
spring.cloud.nacos.config.namespace
默认使用的是public
命名空间,属性值为命名空间的id基于微服务做隔离:也可以给每个微服务创建命名空间,避免所有的微服务配置都放在一个命名空间下以区分微服务避免导致配置文件混乱,命名空间可以配置任意多个
配置集概念:一个微服务应用的所有配置文件集合就是配置集,一个微服务可以有无数个配置文件,配置集ID就是DataID
xxxxxxxxxx
spring.cloud.nacos.config.namespace=a88106bf-1518-42a8-935e-bde7b0039596
配置分组
默认所有的配置集都属于配置集:DEFAULT_GROUP;效果是随意定义一组完整配置,在需要的情况下整组切换配置,如双十一用一组配置,双十二用一组配置;组的创建只需要在配置中心创建配置文件的时候自定义组名即可,然后在bootstrap中通过属性
spring.cloud.nacos.config.group
进行配置,不写会使用默认组DEFAULT_GROUP也可以用这种方式来区分运行环境
xxxxxxxxxx
#这个就可以表示双十一使用的配置组
spring.cloud.nacos.config.group=1111
拆分配置文件
一般不会让所有的配置写在一个文件中,这样会导致文件的不好管理;一般都拆分成数据源写入datasource.yml、MybatisPlus的配置放在mybatisplus.yml中,这里只是演示,实际上不需要变更的配置一般放本地,需要热更新的配置才会放在配置中心
依然会读取默认DataId的配置文件
这个特性周阳老师没讲过
xxxxxxxxxx
#配置加载除了默认的dataId配置文件,还需要额外加载哪些DataId的配置文件,这个属性是一个数组,可以配置多个,
# 每个不同的配置文件使用下标0,1,2...进行区分
# 配置额外加载dataId为datasource.yml的配置文件
spring.cloud.nacos.config.ext-config[0].data-id=datasource.yml
#配置加载配置分组1111下的dataId为datasource.yml的配置文件
spring.cloud.nacos.config.ext-config[0].group=1111
#配置dataId为datasource.yml的配置文件是否能动态刷新,默认是false
spring.cloud.nacos.config.ext-config[0].refresh=true
spring.cloud.nacos.config.ext-config[1].data-id=mybatisplus.yml
spring.cloud.nacos.config.ext-config[1].group=1111
spring.cloud.nacos.config.ext-config[1].refresh=true
spring.cloud.nacos.config.ext-config[2].data-id=others.yml
spring.cloud.nacos.config.ext-config[2].group=1111
spring.cloud.nacos.config.ext-config[2].refresh=true
配置nacos的日志打印级别
nacos经常频繁打印一些不重要的日志信息,打印com.alibaba.nacos.client.naming
相关的可以通过以下代码调高nacos对应信息的日志级别
xxxxxxxxxx
logging
level
com.alibaba.nacos.client.naming WARN
引入OpenFeign的依赖
引入SpringCloud相关组件的版本控制
xxxxxxxxxx
<properties>
<spring-cloud.version>Greenwich.SR3</spring-cloud.version>
</properties>
<dependencyManagement>
<dependencies>
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-dependencies</artifactId>
<version>${spring-cloud.version}</version>
<type>pom</type>
<scope>import</scope>
</dependency>
</dependencies>
</dependencyManagement>
引入OpenFeign依赖
xxxxxxxxxx
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-starter-openfeign</artifactId>
</dependency>
在调用服务下定义一个调用指定服务的接口
服务调用接口统一写在feign包下,这个接口对应被调用服务有对应的实现,有参数需要传递参数
OpenFeign处理参数为对象,参数前带@RequestBody
注解发起请求的逻辑
如果Feign接口的请求参数是一个对象且请求参数前面带@RequestBody
注解,Feign会将该对象转成json字符串
通过Feign接口上的@FeignClient
注解标注的value属性根据服务名去注册中心找到对应的服务地址,向对应地址发起请求,因为参数为对象且有@RequestBody
注解会自动将json字符串放在请求体中
对象服务接收到请求体,发现请求体中有json字符串,且对应的控制器方法参数前有@RequestBody
注解,就会尝试使用参数类型的对象去接收json串,只要属性名一致,不论参数类型是什么类型都能进行匹配,即接收请求的对象和发起请求转换为json的DT对象即使不是同一种类型的对象,只要有相同的属性名就能尝试解析【即只要json数据模型是兼容的,双方服务发起远程调用时传输和接收数据无需使用同一种TO类型】
xxxxxxxxxx
"mall-coupon")//指定远程被调用服务的服务名 (
public interface CouponFeign {
/**
* @return {@link R }
* @描述 指定目标服务被调用的方法,方法需要声明返回值类型、对应的方法名和请求uri
* @author Earl
* @version 1.0.0
* @创建日期 2024/01/27
* @since 1.0.0
*/
"/coupon/coupon/user/list") (
public R queryUserCouponList();
}
在调用服务的启动类上添加@EnableFeignClients注解并指定服务调用接口所在的包
开启OpenFeign的服务调用功能并指定服务调用接口所在的包
🔎:注意没有这个注解EnableFeignClients
即使标注了@FeignClient
注解组件也无法被注入IoC容器
xxxxxxxxxx
"com.earl.mall.user.feign") (
public class MallUserApplication {
public static void main(String[] args) {
SpringApplication.run(MallUserApplication.class, args);
}
}
在业务方法中使用远程调用接口
xxxxxxxxxx
private CouponFeign couponFeign;
/**
* @return {@link R }
* @描述 查询用户账户下的可用优惠券
* @author Earl
* @version 1.0.0
* @创建日期 2024/01/27
* @since 1.0.0
*/
"/coupon/list") (
public R queryUserCouponList(){
return couponFeign.queryUserCouponList();
}
对服务调用超时的降级处理
xxxxxxxxxx
注意事项
在请求路径中添加参数最好在FeignClient
中使用@RequestParam
给参数指定参数名,否则即使参数列表的参数名相同也可能在被调用接口无法获取到传入的参数
xxxxxxxxxx
/**
* @author Earl
* @version 1.0.0
* @描述 购物车远程调用客户端
* @创建日期 2024/11/10
* @since 1.0.0
*/
"mall-cart") (
public interface CartFeignClient {
/**
* @param userId
* @return {@link List }<{@link OrderStatementVo.SelectedCartItemVo }>
* @描述 根据userId获取用户购物车中被选中的购物项
* @author Earl
* @version 1.0.0
* @创建日期 2024/11/10
* @since 1.0.0
*/
"/cart/selected/item") (
List<OrderStatementVo.SelectedCartItemVo> getSelectedCartItem( ("userId") Long userId);
}
//被调用接口
/**
* @return {@link R }
* @描述 根据用户id查询用户所有收货地址
* @author Earl
* @version 1.0.0
* @创建日期 2024/11/11
* @since 1.0.0
*/
"/get") (
public List<MemberReceiveAddressEntity> listByUserId(Long userId){
return memberReceiveAddressService.listByUserId(userId);
}
异步编排Feign远程调用功能会出现的问题
使用我们的自定义线程池和CompletableFuture
异步编排我们的远程调用任务查询出订单确认页需要的各种信息
注意这里异步编排Feign
远程调用又会出问题,这个异步导致执行远程调用的线程变了,同一个线程下共享请求数据的RequestContextHolder
变成不同线程下不共享了,会导致拦截器中从RequestContextHolder
请求上下文保持器中获取请求直接获取到空,从空的请求中获取请求头数据直接报空指针异常,这就是换了线程池的线程来执行异步任务无法从线程池中的线程获取到最初用户请求放入ThreadLocal
中的请求信息,当前线程都不同了,也没有向ThreadLocal
中添加过对应当前线程的请求属性,自然什么都取不到
解决办法
解决办法是在主线程中使用RequestContextHolder.getRequestAttributes()
获取请求属性,异步编排任务中,再执行一次RequestContextHolder.setRequestAttributes()
给异步任务所在线程设置主线程的请求属性,即把之前主线程共享的数据在所有异步线程中都共享一次,但是没有说也没有做怎么移除异步线程的共享数据
但是还有问题,设置请求属性是我们手动设置的,而且线程池中的线程肯定会复用,线程对应的RequestContextHolder
中的请求属性是否需要清除呢?或者每次执行异步任务重新设置会直接覆盖掉旧的请求属性呢
controller
远程被调用的方法一定要加上@ResponseBody
或者直接加@RestController
。就是返回对象一定要加@ResponseBody
注解或者@RestController
注解
代码示例
[配置请求拦截器携带cookie]
xxxxxxxxxx
/**
* @author Earl
* @version 1.0.0
* @描述 自定义Feign远程调用配置
* @创建日期 2024/11/12
* @since 1.0.0
*/
public class CustomFeignConfig {
"requestInterceptor") (
public RequestInterceptor requestInterceptor(){
return template -> {
/*这个从请求上下文保持器中获取请求对象的代码需要放在RequestInterceptor的apply方法中,否则从请求上下文保持器中获取请求的时候请求还没有来
在服务初始化时获取不到请求对象会导致发生异常导致RequestInterceptor初始化失败进而导致Feign客户端初始化失败*/
ServletRequestAttributes requestAttributes =(ServletRequestAttributes) RequestContextHolder.getRequestAttributes();
HttpServletRequest request = requestAttributes.getRequest();
template.header("cookie",request.getHeader("cookie"));
};
}
}
[异步线程远程调用携带cookie的远程调用示例]
xxxxxxxxxx
/**
* @author Earl
* @version 1.0.0
* @描述 订单前台业务实现类
* @创建日期 2024/11/10
* @since 1.0.0
*/
"orderWebService") (
public class OrderWebServiceImpl implements OrderWebService {
private CartFeignClient cartFeignClient;
private MemberFeignClient memberFeignClient;
private ThreadPoolExecutor executor;
/**
* @return {@link OrderStatementVo }
* @描述 获取封装订单确认页数据
* @author Earl
* @version 1.0.0
* @创建日期 2024/11/10
* @since 1.0.0
*/
public OrderStatementVo getOrderStatement() {
UserBaseInfoVo userBaseInfo = LoginStatusInterceptor.loginUser.get();
//6. 获取请求信息,准备在异步远程调用任务中给异步线程封装请求信息
RequestAttributes requestAttributes = RequestContextHolder.getRequestAttributes();
//3. 准备异步封装数据
OrderStatementVo orderStatement = new OrderStatementVo();
//1. 获取用户表中所有地址
CompletableFuture<Void> addressFuture = CompletableFuture.runAsync(() -> {
RequestContextHolder.setRequestAttributes(requestAttributes);
List<OrderStatementVo.UserAddressVo> userAddresses = memberFeignClient.listByUserId(userBaseInfo.getId());
orderStatement.setUserAddresses(userAddresses);
},executor);
//2. 获取用户购物车中被选中的购物项
CompletableFuture<Void> cartFuture = CompletableFuture.runAsync(() -> {
RequestContextHolder.setRequestAttributes(requestAttributes);
List<OrderStatementVo.SelectedCartItemVo> selectedCartItems = cartFeignClient.getSelectedCartItem(userBaseInfo.getId());
orderStatement.setCartItems(selectedCartItems);
},executor);
//4. 设置用户积分
orderStatement.setUserCredits(userBaseInfo.getIntegration());
//5. 等待异步任务执行完
try {
CompletableFuture.allOf(addressFuture,cartFuture).get();
} catch (Exception e) {
e.printStackTrace();
}
return orderStatement;
}
}
查看OpenFeign响应内容
远程OpenFeign调用点击step into--进入reflectiveFeign.invoke()
下一步直到dispatch.get(method).invoke(args)
点击step into--进入synchronousMethodHandler.invoke(Object[] argv)
下一步到executeAndDecode(template)
点击step into--进入synchronousMethodHandler.executeAndDecode(RequestTemplate template)
下一步到response=client.execute(request,options)
执行完毕
上述步骤执行完毕以后response中会保存status
属性[本次请求的状态码]、request
属性[请求对象]、body
属性[响应体内容,body.inputStream.this$0.request.keys
是请求头数据,body.inputStream.this$0.responses.keys
是响应头数据,body.inputStream.this$0.http
是远程调用重定向地址]
网关需要一个单独的服务,一般都在基础依赖配置好了SpringBoot和OpenFeign,也会配置服务注册与发现,spring-boot-starter-web不一定,因为网关不能配置这个,网关使用的是webflux,和springMVC会发生冲突,完整的网关依赖只需要nacos注册中心依赖和gateway依赖
需要引入服务注册与服务发现依赖
其实分布式项目一般都把这个配置写在基础依赖或者父pom中,作为系统的基础设置
xxxxxxxxxx
<!--nacos注册中心-->
<dependency>
<groupId>com.alibaba.cloud</groupId>
<artifactId>spring-cloud-alibaba-nacos-discovery</artifactId>
</dependency>
【网关单独配置】
网关需要配置spring-cloud-starter-gateway
xxxxxxxxxx
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-starter-gateway</artifactId>
</dependency>
在网关启动类上添加注解@EnableDiscoveryClient开启服务注册与发现
这样网关才能知道服务的位置来路由请求
xxxxxxxxxx
exclude = {DataSourceAutoConfiguration.class}) (
public class MallGatewayApplication {
public static void main(String[] args) {
SpringApplication.run(MallGatewayApplication.class, args);
}
}
在配置文件配置注册中心和配置中心的位置
注册中心写在application.yml中,配置中心地址写在bootstrap中
【配置中心】
xxxxxxxxxx
Spring.application.name=mall-gateway
spring.cloud.nacos.config.server-addr=localhost:8848
spring.cloud.nacos.config.namespace=7a52f0fa-5dcf-467d-a285-2ea2c382697b
spring.profiles.active=dev
【注册中心】
bootstrap中写了应用的名称,可以不在application.yml中配置应用名称
xxxxxxxxxx
server
port88
spring
cloud
nacos
discovery
server-addr localhost8848
配置Gateway路由第三方网站功能
【application.yml】
xxxxxxxxxx
spring
cloud
gateway
routes
id baidu_route
uri https //www.baidu.com
predicates
##如果请求的参数中含有url=baidu就路由到百度
Query=url,baidu
id qq_route
uri https //www.qq.com
predicates
Query=url,qq
id admin_route
uri lb //renren-fast
predicates
#Path断言意思是uri为指定路径就路由到该路由上来,这个表示请求路径的uri以/api开始都路由到renren-fast中
Path=/api/**
filters
#filters过滤器对请求uri进行重写,RewritePath=/api/(?<segment>.*),/renren-fast/$\{egment}表示将api换成renren-fast
RewritePath=/api/(?<segment>.*),/renren-fast/$\ segment
对象存储【Object Storage Service】适合存放任意类型的文件,阿里云的对象存储每个月免费5个G
控制台界面右边:常用入口--API文档--文档中心打开可以找到对象存储的官方文档
专业术语
Bucket:Bucket就是文件存储的一个容器,推荐一个项目创建一个Bucket
对象/文件:就是存储的一个个文件
地域:创建一个Bucket时会选择所在地域,相同区域内的服务器可以内网互通,比如同一区域服务器和对象存储内网互通是不计入流量费用的
Endpoint:访问域名,文件访问的域【URL除去URI的部分】
AccessKey:包含AccessKeyId
和AccessKeySecret
,存储文件对用户身份进行验证
注意事项
读写权限
公共读:表示对象存储的文件,写入需要密码可以被随意访问
私有:表示文件的读取和写入需要进行身份验证
公共读写:任何人在没有账号密码的请款下都可以操作Bucket
服务端加密不需要,日志查询也不需要
存储类型访问量大使用标准存储【高可靠、高可用、高性能】,一般项目低频访问就行
上传的文件会自动生成一个https地址,可以在任意地方通过该地址对文件进行访问;通过后台服务整合sdk可以通过程序进行文件上传并获取到文件的访问地址
帮助文档在Bucket中的右上角通过SDK管理文件--JavaSDK
文件存储方式
方式一:用户文件上传,服务端拿到用户的文件流,使用Java代码将文件上传到OSS
但是这种方式不好,文件需要经过用户自己的服务器,还会经过网关,消耗系统性能,用户量大的情况下会带来瓶颈
但是安全,因为账号密码由服务器自己控制
让用户直传阿里云服务器需要将账号密码写在js代码中让浏览器直接发送请求给阿里云服务器,但是这种方式不安全,存在账号密码泄露的问题
人数一多服务器文件上传非常占用带宽,服务器就无法处理别的请求了
方式二:服务端签名后直传
上传前请求应用服务器获取防伪签名令牌,浏览器带着防伪令牌和文件直接访问阿里云服务器
用户上传前先向应用服务器请求上传策略、应用服务器使用阿里云的账号密码生成一个防伪签名,签名信息包括用户访问阿里云的授权令牌,文件上传到阿里云的哪个位置等信息,防伪签名中不含账号密码【和https非对称加密原理是一样的,请求由公钥加密,私钥解密;响应由私钥加密,公钥解密;公钥加密的内容公钥无法解密】,保证了服务器使用公钥加密的令牌只能被阿里云服务器的私钥进行解密,阿里云返回的数据即便能被泄露的公钥解密也无伤大雅,因为阿里云服务器已经实现了服务器的认证工作
文档位置经常变,目前的参考文档路径首页--对象存储--开发参考--SDK参考--Java,且不好找到对应需要的内容,直接在OSS文档界面搜索需要的文档内容
纯粹的使用Maven工程的JavaWeb程序
实现流程
在发起签名的服务端的Maven工程中加入依赖项安装OSS Java SDK
xxxxxxxxxx
<dependency>
<groupId>com.aliyun.oss</groupId>
<artifactId>aliyun-sdk-oss</artifactId>
<version>3.15.1</version>
</dependency>
Java 9及以上的版本,还需要添加JAXB相关依赖
xxxxxxxxxx
<dependency>
<groupId>javax.xml.bind</groupId>
<artifactId>jaxb-api</artifactId>
<version>2.3.1</version>
</dependency>
<dependency>
<groupId>javax.activation</groupId>
<artifactId>activation</artifactId>
<version>1.1.1</version>
</dependency>
<!-- no more than 2.3.3-->
<dependency>
<groupId>org.glassfish.jaxb</groupId>
<artifactId>jaxb-runtime</artifactId>
<version>2.3.3</version>
</dependency>
参考官方文档的上传文件编写文件上传代码
【原生的文件上传代码】
文件上传使用上传文件流的示例代码,这个代码是服务端上传文件流的代码
这个
accessKeyId
,accessKeySecret
官网为了安全显示一次以后就查不到了,一定要保存好
xxxxxxxxxx
public void testFileUploadOss() throws FileNotFoundException {
//EndPoint根据Bucket的实际填写
String endPoint="oss-cn-chengdu.aliyuncs.com";
//操作阿里云的子账户,因为这里填写账号密码很容易丢失,这是避免阿里云账号丢了所以专门弄的一个针对bucket操作的账号密码,丢了可以直接在RAM界面禁用删除重新生成
String accessKeyId="<yourAccessKeyId>";
String accessKeySecret="<yourAccessKeySecret>";
//使用以上三个信息创建对象存储操作的ossClient实例
OSS ossClient = new OSSClientBuilder().build(endPoint, accessKeyId, accessKeySecret);
//拿到文件流
FileInputStream inputStream = new FileInputStream("E:\\1.jpg");
//上传文件
//yourBucketName是存储空间的名字即自定义Bucket的名字
//yourObjectName就是上传文件的名字,文件名字要带后缀
//ossClient.putObject("<yourBucketName>","yourObjectName",inputStream)
ossClient.putObject("demo2-mall","1.jpg",inputStream);
ossClient.shutdown();
System.out.println("上传完成");
}
点击主账号-AccessKey-用户新建一个子用户,访问方式选择编程访问【通过API和开发工具访问】
创建以后能看到用户列表和对应的accessKeyId和accessKeySecret,默认创建的账户没有任何权限,点击添加权限为用户添加OSS完全访问权限【上传文件需要完全访问权限】,使用这个用户的账号密码给阿里云OSS上传文件
SpringCloud Alibaba整合对象存储,这个starter中整合了各自上传方法,不需要再像原生依赖一样每种上传方式自己写上传代码,只需要导入starter,提供accessKeyId和accessKeySecret以及endPoint调用方法即可实现文件上传
实现流程
导入alicloud-oss starter
该starter会自动导入原生的aliyun-sdk-oss
xxxxxxxxxx
</dependencies>
<!--对象存储-->
<dependency>
<groupId>com.alibaba.cloud</groupId>
<artifactId>spring-cloud-starter-alicloud-oss</artifactId>
</dependency>
</dependencies>
<dependencyManagement>
<dependencies>
<dependency>
<groupId>com.alibaba.cloud</groupId>
<artifactId>spring-cloud-alibaba-dependencies</artifactId>
<version>2.1.0.RELEASE</version>
<type>pom</type>
<scope>import</scope>
</dependency>
</dependencies>
</dependencyManagement>
在配置文件中配置OSS服务对应的accessKeyId和accessKeySecret以及endPoint
xxxxxxxxxx
Spring
cloud
alicloud
access-key <yourAccessKeyId>
secret-key <yourAccessKeySecret>
oss
endpoint <你的Bucket所在地>
使用方法
自动注入直接使用
xxxxxxxxxx
private OSSClient ossClient;
public void testFileUploadOss() throws FileNotFoundException {
//拿到文件流
FileInputStream inputStream = new FileInputStream("E:\\1.jpg");
//上传文件
//ossClient.putObject("<yourBucketName>","yourObjectName",inputStream)
ossClient.putObject("demo2-mall","1.jpg",inputStream);
//这一步存疑,应该不需要关闭才对
ossClient.shutdown();
System.out.println("上传完成");
}
服务端不带回调版浏览器直传文件到阿里云OSS服务器,文件流不经过应用服务器;流程为浏览器发送文件前先请求应用服务器拿到服务端颁发的签名,浏览器直接拿着签名和文件请求阿里云服务器
前端文件上传组件和对服务端签名处理的代码见前端--前端常用组件
服务端不带回调版流程
浏览器上传文件数据前向应用服务端发起请求获取签名
官方Java服务端签名代码
xxxxxxxxxx
protected void doGet(HttpServletRequest request, HttpServletResponse response)
throws ServletException, IOException {
//这还是JavaWeb的签名证书代码,实际上SpringBoot直接从配置中心拉取Oss的配置,封装成ossClient自动注入,
// 所以从下面到String bucket = "examplebucket";相关代码可以删除
// 从环境变量中获取访问凭证。运行本代码示例之前,请确保已设置环境变量OSS_ACCESS_KEY_ID和OSS_ACCESS_KEY_SECRET。
EnvironmentVariableCredentialsProvider credentialsProvider = null;
try {
//这是从环境变量中获取OSS_ACCESS_KEY_ID和OSS_ACCESS_KEY_SECRET的代码
credentialsProvider = CredentialsProviderFactory.newEnvironmentVariableCredentialsProvider();
} catch (ClientException e) {
e.printStackTrace();
}
// Endpoint以华东1(杭州)为例,其他Region请按实际情况填写。
String endpoint = "oss-cn-hangzhou.aliyuncs.com";
// 填写Bucket名称,例如examplebucket。默认的alicloud oss starter中没有bucket属性,可以自己配置在配置文件中
String bucket = "examplebucket";
// 填写Host名称,格式为https://bucketname.endpoint。
String host = "https://examplebucket.oss-cn-hangzhou.aliyuncs.com";
// 设置上传回调URL,即回调服务器地址,用于处理应用服务器与OSS之间的通信。OSS会在文件上传完成后,把文件上传信息通过此回调URL发送给应用服务器。
// 上传回调暂时不用
String callbackUrl = "https://192.168.0.0:8888";
// 设置上传到OSS文件的前缀,可置空此项。置空后,文件将上传至Bucket的根目录下。一般都设置每一天都产生一个新目录,方便管理
//String dir = "exampledir/";
//使用java8新特性根据日期以2024/2/27/的格式生成目录
String dir = LocalDateTime.now().format(DateTimeFormatter.ofPattern("yyyy/MM/dd")) + "/";
// 创建OSSClient实例。
OSS ossClient = new OSSClientBuilder().build(endpoint, credentialsProvider);
try {
long expireTime = 30;
long expireEndTime = System.currentTimeMillis() + expireTime * 1000;
Date expiration = new Date(expireEndTime);
// PostObject请求最大可支持的文件大小为5 GB,即CONTENT_LENGTH_RANGE为5*1024*1024*1024。
PolicyConditions policyConds = new PolicyConditions();
policyConds.addConditionItem(PolicyConditions.COND_CONTENT_LENGTH_RANGE, 0, 1048576000);
policyConds.addConditionItem(MatchMode.StartWith, PolicyConditions.COND_KEY, dir);
String postPolicy = ossClient.generatePostPolicy(expiration, policyConds);
byte[] binaryData = postPolicy.getBytes("utf-8");
String accessId = credentialsProvider.getCredentials().getAccessKeyId();
String encodedPolicy = BinaryUtil.toBase64String(binaryData);
String postSignature = ossClient.calculatePostSignature(postPolicy);
Map<String, String> respMap = new LinkedHashMap<String, String>();
respMap.put("accessid", accessId);
respMap.put("policy", encodedPolicy);
respMap.put("signature", postSignature);
respMap.put("dir", dir);
respMap.put("host", host);
respMap.put("expire", String.valueOf(expireEndTime / 1000));
// respMap.put("expire", formatISO8601Date(expiration));
/**雷神补充:上面给Map中放了一些属性数据,这些属性统一最后以跨域的方式响应出去,
* 由于跨域最后在网关统一解决,设置头信息等,下面这部分代码直接删除
* 自己注:这儿可能是由于跨域的问题没有相应的包,现有的所有JSONObject类都不适配该代码块
* 1. 因为下面是转换签名信息为json格式数据,实际上SpringBoot会自己通过响应数据处理器自动将Map转成json,所以
* JSONObject相关代码可以删除
* 2. 第二个是该代码示例中包含了该服务对跨域问题的处理,在响应头中添加对所有请求的跨域支持,实际上这部分代码已经在
* 网关中通过过滤器解决了,响应也不需要用户再手动处理,直接把Map返回给DispatchServlet即可
*/
JSONObject jasonCallback = new JSONObject();
jasonCallback.put("callbackUrl", callbackUrl);
jasonCallback.put("callbackBody",
"filename=${object}&size=${size}&mimeType=${mimeType}&height=${imageInfo.height}&width=${imageInfo.width}");
jasonCallback.put("callbackBodyType", "application/x-www-form-urlencoded");
String base64CallbackBody = BinaryUtil.toBase64String(jasonCallback.toString().getBytes());
respMap.put("callback", base64CallbackBody);
JSONObject ja1 = JSONObject.fromObject(respMap);
// System.out.println(ja1.toString());
response.setHeader("Access-Control-Allow-Origin", "*");
response.setHeader("Access-Control-Allow-Methods", "GET, POST");
response(request, response, ja1.toString());
} catch (Exception e) {
// Assert.fail(e.getMessage());
System.out.println(e.getMessage());
} finally {
ossClient.shutdown();
}
}
SpringBoot服务端签名实例
SpringBoot引入alicloud-oss starter的服务端签名实例
【alicloud-oss starter依赖】
xxxxxxxxxx
<!--对象存储-->
<dependency>
<groupId>com.alibaba.cloud</groupId>
<artifactId>spring-cloud-starter-alicloud-oss</artifactId>
</dependency>
【yml相关配置】
xxxxxxxxxx
spring
cloud
alicloud
access-key <yourAccessKeyId>
secret-key <yourAccessKeySecret>
bucket <bucketName>
oss
endpoint <yourEndpoint>
【完整签名发布代码】
xxxxxxxxxx
"/oss") (
public class OssController {
/**
*注意ossClient注入容器是以接口类型OSS进行注入的,这个组件是通过Oss相关的AutoConfiguration进行配置的
* OssContextAutoConfiguration是OSS的环境配置,ossClient组件的配置在OssContextAutoConfiguration中
* 第一个组件就是以OSS接口类型对ossClient进行配置,所以自动注入的时候,ossClient的类型要写成OSS、
* 不能写成OSSClient,注意OSS中是有OSSClient这个类的,OSSClient是OSS的一种实现类型
* 弹幕说使用@Resource注解不会产生该问题,因为@Resource注解会自动强转,确实有效,原因是@Autowired注解默认是通过类型注入,而@Resource注解默认是通过名字进行注入,@Resource注解会拿着名字去匹配组件的名字
*
*/
private OSSClient ossClient;
"${spring.cloud.alicloud.oss.endpoint}") (
private String endpoint;
"${spring.cloud.alicloud.access-key}") (
private String accessId;
"${spring.cloud.alicloud.bucket}") (
private String bucket;
"/policy") (
public Map<String,String> policy(){
// 填写Host名称
String host = "https://"+bucket+"."+endpoint;
//使用java8新特性根据日期以2024/2/27/的格式生成目录
String dir = LocalDateTime.now().format(DateTimeFormatter.ofPattern("yyyy/MM/dd")) + "/";
//封装签名信息
Map<String, String> respMap = new LinkedHashMap<String, String>();
try {
long expireTime = 30;
long expireEndTime = System.currentTimeMillis() + expireTime * 1000;
Date expiration = new Date(expireEndTime);
// PostObject请求最大可支持的文件大小为5 GB,即CONTENT_LENGTH_RANGE为5*1024*1024*1024。
// 设置文件上传策略policyConds,包括文件最大大小和文件路径,该策略将和有效时间一起生成postPolicy
// postPolicy使用Base64编码后通过ossClient生成客户端签名
PolicyConditions policyConds = new PolicyConditions();
policyConds.addConditionItem(PolicyConditions.COND_CONTENT_LENGTH_RANGE, 0, 1048576000);
policyConds.addConditionItem(MatchMode.StartWith, PolicyConditions.COND_KEY, dir);
String postPolicy = ossClient.generatePostPolicy(expiration, policyConds);
byte[] binaryData = postPolicy.getBytes("utf-8");
String encodedPolicy = BinaryUtil.toBase64String(binaryData);
String postSignature = ossClient.calculatePostSignature(postPolicy);
//传递给浏览器的内容包括操作Oss的用户ID、编码策略、服务端签名[签名中包含了AccessKeySecret]、文件上传的bucket地址和签名有效时间
//Oss用户的Id
respMap.put("accessid", accessId);
//编码策略
respMap.put("policy", encodedPolicy);
//签名
respMap.put("signature", postSignature);
//文件上传目录
respMap.put("dir", dir);
//文件上传地址
respMap.put("host", host);
//有效期30s
respMap.put("expire", String.valueOf(expireEndTime / 1000));
// respMap.put("expire", formatISO8601Date(expiration));
} catch (Exception e) {
// Assert.fail(e.getMessage());
System.out.println(e.getMessage());
} finally {
ossClient.shutdown();
return respMap;
}
}
}
【签名效果】
阿里云云市场提供了很多比如短信发送、物流查询、实名认证查询等等接口,这些接口需要付费使用,阿里云的发送短信验证码服务都是外包的,有很多商家,可以选择合适的自己进行使用,根据产品文档介绍自己抽取组价使用就行了,主要三个方面,一个是抽取组件注入容器按需使用,第二是凡是需要自己配置的参数全部抽取到配置文件进行配置方便管理和修改,第三是短信发送业务使用服务器来进行请求防止暴露身份验证信息导致出现安全问题
短信接口阿里云有0元五次的短信接口,正常情况下一条短信大概五分钱一次,测试可以使用该免费服务,购买成功可以在管理控制台看到对应的商品信息,购买界面下方有接口使用方法介绍,选择短信验证码接口
这里使用的接口调用地址【GET】http
,通过APPCODE
的方式进行调用者的身份权限验证
APPCODE
在我们管理控制台购买的服务中可以看到对应的AppCode
,发起请求时需要携带该AppCode
在请求中AppCode
会在请求头信息中的Authorization
字段中指定,格式为APPCODE+半角空格+APPCODE值
,不添加该请求头信息请求会报错401
这种方式意味着直接通过用户客户端发起短信请求不安全,因为用户可以获取到我们的APPCODE
,高频发起请求对我们的短信服务造成金钱攻击,一般的做法是用户向我们的服务器发起请求,我们生成一个随机验证码并通过服务器向短信接口发起请求向用户手机发起指定验证码
请求时需要在请求参数中携带以下四个String类型的参数
GET请求请求参数直接拼接在请求路径后面
名称 | 描述 | 是否必须 |
---|---|---|
code | 要发送的验证码 | 必选 |
phone | 接收人的手机号 | 必选 |
sign | 签名编号 签名是短信抬头括号中的内容,一般用来标记短信的发送者 签名如果默认的编号1对应的签名为消息秘书 这个自定义签名需要添加客服来申请编号和自定义签名的对应关系才能使用自定义签名 | 可选 |
skin | 短信模板编号 短信模板是短信服务提供商提供的一系列短信模板 可以通过skin顺序整数编号来指定用户希望使用的模板 | 可选 |
短信接口的Java代码示例购买界面的文档中也有
可能需要一些短信服务商的工具类比如HttpUtils
来处理请求参数需要到指定地址下载并拷贝到自己的项目中
需要引入的依赖文档中也有
服务端口配置
xxxxxxxxxx
Server
port7000
服务名配置
xxxxxxxxxx
spring.application.name = mall-search
格式化所有响应前端的Date类型数据被格式化为指定格式并将时区设置为东八区
如果只是个别属性需要将Date类型格式化为指定格式可以在指定属性上标注注解@JsonFormat(pattern="yyyy-MM-dd HH:mm:ss",timezone="GMT+8")
这种操作有风险,指定了日期时间格式,如果前端修改时间传参还是2024-12-09T00:00:00.000+0000
就无法将其转换成Date
类型参数服务器会直接报错;前端要修改时间只能传参yyyy-MM-dd HH:mm:ss
格式的数据,但是一般前端传递的数据格式后端开发是很难左右的
xxxxxxxxxx
Spring:
jackson:
date-format: yyyy-MM-dd HH:mm:ss
time-zone: GMT-8
@ControllerAdvice
简介
@ControllerAdvice
标注的类本质上是一个Component
,该注解定义上标注了`@Component
注解
该注解标注的类的作用是为注解中basePackages
属性指定的包下所有Controller
提供使用注解@ExceptionHandler
、@InitBinder
或 @ModelAttribute
标注的方法
如使用
@ExceptionHandler
注解对控制器方法中抛出的指定异常进行全局处理,就是aop思想的一种实现,用户通过这个注解标注的类指定拦截规则,执行过程抛出对应异常自动拦下来,具体用户的拦截异常筛选和拦截之后的处理,用户通过@ExceptionHandler
、@InitBinder
或@ModelAttribute
这三个注解以及被其标注的方法来自定义
@ControllerAdvice
指定Controller
的范围
默认@ControllerAdvice
注解什么都不写,则默认适用于全体的Controller
指定@ControllerAdvice
注解的value
属性,比如写成@ControllerAdvice("org.my.pkg")
意思是适用于org.my.pkg
包及其子包下的所有Controller
,写成@ControllerAdvice(basePackages={"org.my.pkg", "org.my.other.pkg"})
的形式是以数组的形式指定多个包下的Controller
,basePackages
属性是value
属性的别名;
annotations
属性是匹配标注了指定注解的Controller
,@ControllerAdvice(annotations={CustomAnnotation.class})
是匹配所有被这个注解@CustomAnnotation
修饰的 Controller
,属性值可以为数组,同时还可以是自定义注解
@ControllerAdvice
实现全局异常处理
@ControllerAdvice
标注的类中配置@ExceptionHandler
标注的方法实现对控制器方法指定异常进行全局处理
@ExceptionHandler
注解的value属性是Throwable的子类数组,Throwable是所有异常的父类,即通过指定异常类数组来指明方法对应处理的被控制器抛出的指定异常类
@ExceptionHandler
注解标注的对全局异常处理的返回值应该是ModelAndView,这个返回值和控制器方法的返回值的用法是一样的,也可以封装自定义的响应数据格式如R,如果该方法要返回json格式的数据需要像控制器方法一样给该方法标注@ResponseBody
注解,该注解可以被移到定义该方法的被@ControllerAdvice
注解标注的类上,此时又有一个@RestControllerAdvice
注解是@ControllerAdvice
注解和@ResponseBody
注解的合体
实例:
这个异常信息处理很粗糙,按需要自己写异常处理
xxxxxxxxxx
//@ResponseBody
//@ControllerAdvice(basePackages = "com.earl.mall.product.controller")
basePackages = "com.earl.mall.product.controller") (
public class MallControllerExceptionHandler {
Exception.class) (
public R handleValidException(Exception e){
log.error("数据校验错误:{},异常类型:{}",e.getMessage(),e.getClass());
return R.error();
}
}
@ControllerAdvice
标注的类中配置@ModelAttribute
标注的方法实现预设全局数据【就是在model中放数据,在指定的控制器方法中可以拿出来】
@ModelAttribute
的作用是绑定一些变量到被@ModelAttribute
标注的方法的返回值或者方法参数列表中的Model
类型参数中供被指定的Controller
中注有@RequestMapping
的方法进行使用,类似与@GetMapping
这种注解都被@RequestMapping
注解标注了
@ModelAttribute
的value属性是指定当被标注方法有返回值时返回值的key,没有指定时以返回值的变量名作为key
全局参数绑定
方式一
向被
@ModelAttribute
注解标注的方法的参数列表中的model中使用addAttribute
方法添加键值对,可以添加多个键值对
xxxxxxxxxx
public class MyGlobalHandler {
public void presetParam(Model model){
model.addAttribute("globalAttr","this is a global attribute");
}
}
方式二
被
@ModelAttribute
注解标注的方法返回一个Map,Map中存放键值对当
@ModelAttribute
不传任何参数时会以返回值的变量名作为Map的key,如果value有值就使用该值作为Map的key,这个value作为map的名字我没看懂,从全局参数的使用上来说完全没有看到Map参数名需要使用的影子
xxxxxxxxxx
public class MyGlobalHandler {
()
public Map<String, String> presetParam(){
Map<String, String> map = new HashMap<String, String>();
map.put("key1", "value1");
map.put("key2", "value2");
map.put("key3", "value3");
return map;
}
}
全局参数的使用
实例
xxxxxxxxxx
public class AdviceController {
"methodOne") (
public String methodOne(Model model){
Map<String, Object> modelMap = model.asMap();
return (String)modelMap.get("globalAttr");
}
"methodTwo") (
public String methodTwo( ("globalAttr") String globalAttr){
return globalAttr;
}
"methodThree") (
public String methodThree(ModelMap modelMap) {
return (String) modelMap.get("globalAttr");
}
}
@ControllerAdvice
标注的类中配置@InitBinder
标注的方法实现请求参数的预处理
//TODO 此处@ControllerAdvice下的内容需要整理
@IniiBinder
源码
xxxxxxxxxx
/**
* Annotation that identifies methods which initialize the
* {@link org.springframework.web.bind.WebDataBinder} which
* will be used for populating command and form object arguments
* of annotated handler methods.
* 粗略翻译:此注解用于标记那些 (初始化[用于组装命令和表单对象参数的]WebDataBinder)的方法。
* 原谅我的英语水平,翻译起来太拗口了,从句太多就用‘()、[]’分割一下便于阅读
*
* Init-binder methods must not have a return value; they are usually
* declared as {@code void}.
* 粗略翻译:初始化绑定的方法禁止有返回值,他们通常声明为 'void'
*
* <p>Typical arguments are {@link org.springframework.web.bind.WebDataBinder}
* in combination with {@link org.springframework.web.context.request.WebRequest}
* or {@link java.util.Locale}, allowing to register context-specific editors.
* 粗略翻译:典型的参数是`WebDataBinder`,结合`WebRequest`或`Locale`使用,允许注册特定于上下文的编辑
* 器。
*
* 总结如下:
* 1. @InitBinder 标识的方法的参数通常是 WebDataBinder。
* 2. @InitBinder 标识的方法,可以对 WebDataBinder 进行初始化。WebDataBinder 是 DataBinder 的一
* 个子类,用于完成由表单字段到 JavaBean 属性的绑定。
* 3. @InitBinder 标识的方法不能有返回值,必须声明为void。
*/
ElementType.METHOD}) ({
RetentionPolicy.RUNTIME) (
public @interface InitBinder {
/**
* The names of command/form attributes and/or request parameters
* that this init-binder method is supposed to apply to.
* <p>Default is to apply to all command/form attributes and all request parameters
* processed by the annotated handler class. Specifying model attribute names or
* request parameter names here restricts the init-binder method to those specific
* attributes/parameters, with different init-binder methods typically applying to
* different groups of attributes or parameters.
* 粗略翻译:此init-binder方法应该应用于的命令/表单属性和/或请求参数的名称。默认是应用于所有命 * 令/表单属性和所有由带注释的处理类处理的请求参数。这里指定模型属性名或请求参数名将init-binder * 方法限制为那些特定的属性/参数,不同的init-binder方法通常应用于不同的属性或参数组。
* 我至己都理解不太理解这说的是啥呀,我们还是看例子吧
*/
String[] value() default {};
}
我们来看看具体用途,其实这些用途在
Controller
里也可以定义,但是作用范围就只限当前Controller,因此下面的例子我们将结合ControllerAdvice
作全局处理。
参数处理
xxxxxxxxxx
public class MyGlobalHandler {
public void processParam(WebDataBinder dataBinder){
/*
* 创建一个字符串微调编辑器
* 参数{boolean emptyAsNull}: 是否把空字符串("")视为 null
*/
StringTrimmerEditor trimmerEditor = new StringTrimmerEditor(true);
/*
* 注册自定义编辑器
* 接受两个参数{Class<?> requiredType, PropertyEditor propertyEditor}
* requiredType:所需处理的类型
* propertyEditor:属性编辑器,StringTrimmerEditor就是 propertyEditor的一个子类
*/
dataBinder.registerCustomEditor(String.class, trimmerEditor);
//同上,这里就不再一步一步讲解了
binder.registerCustomEditor(Date.class,
new CustomDateEditor(new SimpleDateFormat("yyyy-MM-dd"), false));
}
}
这样之后呢,就可以实现全局的实现对
Controller
中RequestMapping
标识的方法中的所有String
和Date
类型的参数都会被作相应的处理。
【Controller】
xxxxxxxxxx
public class BinderTestController {
"processParam") (
public Map<String, Object> test(String str, Date date) throws Exception {
Map<String, Object> map = new HashMap<String, Object>();
map.put("str", str);
map.put("data", date);
return map;
}
}
从Map的响应结果可以看出,
str
和date
这两个参数在进入Controller
的test的方法之前已经被处理了,str
被去掉了两边的空格(%20
在Http url 中是空格的意思),String
类型的1997-1-10
被转换成了Date
类型。
参数绑定
参数绑定可以解决特定问题,那么我们先来看看我们面临的问题
xxxxxxxxxx
class Person {
private String name;
private Integer age;
// omitted getters and setters.
}
class Book {
private String name;
private Double price;
// omitted getters and setters.
}
public class BinderTestController {
"bindParam") (
public void test(Person person, Book book) throws Exception {
System.out.println(person);
System.out.println(book);
}
}
我们会发现
Person
类和Book
类都有name
属性,那么这个时候就会出先问题,它可没有那么智能区分哪个name
是哪个类的。因此@InitBinder
就派上用场了:
xxxxxxxxxx
public class MyGlobalHandler {
/*
* @InitBinder("person") 对应找到@RequstMapping标识的方法参数中
* 找参数名为person的参数。
* 在进行参数绑定的时候,以‘p.’开头的都绑定到名为person的参数中。
*/
"person") (
public void BindPerson(WebDataBinder dataBinder){
dataBinder.setFieldDefaultPrefix("p.");
}
"book") (
public void BindBook(WebDataBinder dataBinder){
dataBinder.setFieldDefaultPrefix("b.");
}
}
因此,传入的同名信息就能对应绑定到相应的实体类中:
p.name -> Person.name b.name -> Book.name
还有一点注意的是如果 @InitBinder("value") 中的 value 值和 Controller 中 @RequestMapping() 标识的方法的参数名不匹配,则就会产生绑定失败的后果,如:
@InitBinder(“p”)、@InitBinder(“b”)
public void test(Person person, Book book)
上述情况就会出现绑定失败,有两种解决办法
第一中:统一名称,要么全叫p,要么全叫person,只要相同就行。
第二种:方法参数加 @ModelAttribute,有点类似@RequestParam
@InitBinder(“p”)、@InitBinder(“b”)
public void test(@ModelAttribute(“p”) Person person, @ModelAttribute(“b”) Book book)
配置SpringBoot接口响应json字符串将日期转化为指定格式
yml配置
时区不对的,数据连接时加上时区就行了;
url: jdbc://localhost:3306/databasename?useSSL=false&serverTimezone=Asia/Shanghai
【这是决定插入数据库转换成对应时区的时间】或者配置
jackson time-zone: GMT+8
设置时区,经过测试两个同时配置不会出问题
xxxxxxxxxx
Spring
jackson
date-format yyyy-MM-dd HH mm ss
time-zone GMT+8
在保证多例模式下的并发线程安全的前提下,使用多例模式只需要简单更改配置就能实现系统并发性能的快速提升【比如方法中对数据库的所有操作只使用一条SQL】
但是对于无法保证多例模式并发线程安全问题的情况如使用JVM本地锁切换多例模式就可能导致并发线程安全问题
配置IoC容器组件为多例模式
SpringBoot中的service等IoC组件一般默认都是单例的,通过在组件类名上添加注解@Scope(value="prototype",proxyMode=ScopedProxyMode.TARGET_CLASS)
能够将组件从单例模式改成多例模式
默认该注解@Scope
的value属性值是单例即singleton
,但是单独将value属性设置为prototype
是无法将组件设置为多例模式的,还需要配置proxyMode
属性,proxyMode
属性的默认值是ScopedProxyMode.DEFAULT
,是枚举类ScopedProxyMode
中的一个枚举值DEFAULT
,等价于同一个枚举类ScopedProxyMode
中的另一个枚举值NO
,相当于默认情况下没有指定代理模式,该枚举类下还有两个枚举值INTERFACES
和TARGET_CLASS
,如果想使用JDK代理【基于接口的代理】需要将@Value
注解的proxyMode
属性设置为ScopedProxyMode.INTERFACES
,如果想使用CGLIB代理【基于类的代理】需要将@Value
注解的proxyMode
属性设置为ScopedProxyMode.TARGET_CLASS
🔎:原生的Spring默认是JDK代理,这种代理方式使用Service一般要先定义Service接口再写对应的实现类,在SpringBoot2.x以后默认使用的是CGLib代理,所以使用Service一般不用写接口直接写实现类即可,但是注意,如果没有写Service接口,切换使用Service组件多例模式时就只能指定@Value
注解的proxyMode
属性为ScopedProxyMode.TARGET_CLASS
,否则需要补充对应Service组件的接口
通过在方法上添加@Transactional
注解来控制单体服务同一个方法下对数据库的组合操作的原子性,提交成功则全部多个对数据库操作同时提交成功,失败则全部提交失败
事务注解@Transactional
是通过AOP的思想来实现事务控制的,在方法之前开启事务,在方法之后提交事务或者回滚事物,这种方式在使用JVM本地锁的情况下可能会发生线程安全问题,详见分布式锁--Synchronized解决方案优缺点分析
事务指定隔离级别
通过注解@Transactional(isolation=Isolation.READ_UNCOMMITTED)
指定数据库的事务隔离级别为读未提交,默认隔离级别是Isolation.DEFAULT
即数据库的实际隔离级别,使用读未提交能够解决@Transactional
注解使用JVM本地锁导致的多线程并发线程安全问题,因为JVM本地锁锁不住AOP方式实现的开始事务和提交事务,在不发生事务回滚的情况下读未提交确实能解决JVM本地锁失效的问题,但是在涉及支付等场景下不能为了解决线程安全问题就把数据库的隔离级别改成读未提交,一旦回滚就要爆炸,其他线程可能已经拿着回滚前的数据一泻千里了
视图映射配置
我们可以通过配置WebMvcConfigurer
的子实现配置类,通过重写addViewControllers(ViewControllerRegistry registry)
方法,在该方法中通过多次调用registry.addViewController(String urlPath).setViewName(String viewName)
来一次性设置多个视图路径映射关系,这样可以避免在控制器中写一堆只负责请求路径页面跳转的空方法
默认使用的都是GET请求的方式来处理视图映射
以下配置了URI/login.html
对视图template/login.html
,/registry.html
对视图template/registry.html
的映射
xxxxxxxxxx
/**
* @author Earl
* @version 1.0.0
* @描述 认证服务自定义视图映射器
* 默认使用的都是GET请求的方式来处理视图映射
* @创建日期 2024/10/01
* @since 1.0.0
*/
public class CustomWebMvcConfigurer implements WebMvcConfigurer {
public void addViewControllers(ViewControllerRegistry registry) {
registry.addViewController("/login.html").setViewName("login");
registry.addViewController("/registry.html").setViewName("registry");
}
}
拦截器
拦截器是一个组件,需要标注@Component
注解,同时该组件必须实现HandlerInterceptor
接口
过滤器基于函数回调、拦截器基于反射、AOP基于动态代理
这里使用拦截器是因为购物车的功能都要判断用户的登录状态,因此对通过购物车的所有请求都要对登录状态进行判断,所以设置成拦截器,感觉设置成过滤器一样好使
拦截器配置
拦截器需要实现HandlerInterceptor
接口的以下方法
preHandle(HttpServletRequest request,HttpServletResponse response,Object handler)
方法,该方法的执行时机是在控制器方法执行之前,该方法返回true
就是放行当前请求,该方法返回false
就是拦截不放行当前请求
拦截器业务逻辑为从session
中获取用户数据,如果用户数据不为null
,设置用户id作为传输类UserInfoTo
的UserId
字段
通过request.getCookies()
从用户请求中获取全部的cookie
信息,如果有cookie
信息就遍历cookie
,将名为user-key
的cookie
设置到userInfoTo
的userKey
属性中
即能获取到用户登录信息就设置用户id,如果请求cookie
中携带了名为user-key
的cookie就将该cookie值放在userInfoTo
中,处理完全部放行去控制器方法
如果用户是第一次点击购物车,此时还没有登录,cookie中也没有携带名为user-key
的cookie
,此时我们就要为服务器下发一个名为user-key
的cookie
,使用UUID
来作为cookie
的值,在控制器方法执行前我们生成一个临时用户身份设置到userInfoTo
的userKey
属性中
postHandle(HttpServletRequest request,HttpServletResponse response,Object handler)
方法,该方法的执行时机是控制器方法执行之后,
我们从userInfoTo
中获取userKey
属性并使用response.addCookie(cookie)
为浏览器下发标识临时用户身份的名为user-key
的cookie
,cookie
的作用域设置为顶级域名cookie.setDomain(String domain)
,cookie的有效时间设置为cookie.setMaxAge(int expiry)
该方法默认以秒作为单位
注意ThreadLocal
用完需要调用threadLocal.remove()
进行清空,老师没有清空
xxxxxxxxxx
/**
* @author Earl
* @version 1.0.0
* @描述 用户状态判断拦截器,购物车服务需要判断用户的登录状态
* @创建日期 2024/10/25
* @since 1.0.0
*/
public class UserStatusInterceptor implements HandlerInterceptor {
public static ThreadLocal<UserInfoTo> threadLocal=new ThreadLocal<>();
/**
* @param request
* @param response
* @param handler
* @return boolean
* @描述 从session中获取用户数据,如果用户数据存在,封装用户的id信息
* @author Earl
* @version 1.0.0
* @创建日期 2024/10/25
* @since 1.0.0
*/
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
UserInfoTo userInfo = new UserInfoTo();
UserBaseInfoVo userBaseInfo = (UserBaseInfoVo)request.getSession().getAttribute(MallConstant.SESSION_USER_LOGIN_STATUS_KEY);
if(userBaseInfo!=null){
userInfo.setUserId(userBaseInfo.getId());
}
Cookie[] cookies = request.getCookies();
if(cookies!=null && cookies.length>0){
for (Cookie cookie : cookies) {
if(CartConstant.USER_TEMP_IDENTITY.equals(cookie.getName())){
userInfo.setUserKey(cookie.getValue());
userInfo.setTempUser(true);
}
}
}
if(!userInfo.getTempUser()){
userInfo.setUserKey(UUID.randomUUID().toString());
}
threadLocal.set(userInfo);
return true;
}
/**
* @param request
* @param response
* @param handler
* @param modelAndView
* @描述 如果cookie中没有名为user-key的cookie就添加该cookie
* @author Earl
* @version 1.0.0
* @创建日期 2024/10/25
* @since 1.0.0
*/
public void postHandle(HttpServletRequest request, HttpServletResponse response, Object handler, ModelAndView modelAndView) throws Exception {
UserInfoTo userInfo = threadLocal.get();
if(!userInfo.getTempUser()){
response.addCookie(new Cookie(CartConstant.USER_TEMP_IDENTITY,userInfo.getUserKey()));
}
threadLocal.remove();
}
}
拦截器要工作还必须进行注册,只作为组件放在容器中是不行的,SpringBoot
要给Web服务器添加一些定制化配置可以通过实现WebMvcConfigurer
接口,WebMvcConfigurer
接口中的addInterceptors(InterceptorRegistry registry)
方法向拦截器注册列表中添加拦截器,如果在这里像下面一样创建了拦截器实例就不需要给容器注入相应的拦截器组件了
xxxxxxxxxx
/**
* @author Earl
* @version 1.0.0
* @描述 定制化Web服务器配置
* @创建日期 2024/10/01
* @since 1.0.0
*/
public class CustomWebMvcConfigurer implements WebMvcConfigurer {
/**
* @param registry
* @描述 配置拦截器
* addPathPatterns("/**")是配置拦截器具体拦截的请求路径,这个表示拦截所有请求
* @author Earl
* @version 1.0.0
* @创建日期 2024/10/24
* @since 1.0.0
*/
public void addInterceptors(InterceptorRegistry registry) {
registry.addInterceptor(new UserStatusInterceptor()).addPathPatterns("/**");
}
}